diff --git a/.github/actions/webapp-setup/action.yml b/.github/actions/webapp-setup/action.yml index 77574bd5270..af97cb31c72 100644 --- a/.github/actions/webapp-setup/action.yml +++ b/.github/actions/webapp-setup/action.yml @@ -15,6 +15,9 @@ runs: path: | webapp/node_modules webapp/channels/node_modules + webapp/platform/client/node_modules + webapp/platform/components/node_modules + webapp/platform/types/node_modules key: node-modules-${{ runner.os }}-${{ hashFiles('webapp/package-lock.json') }} - name: ci/cache-platform-builds uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 diff --git a/.github/workflows/api.yml b/.github/workflows/api.yml index 1369ab4d1e6..257c22d6e0b 100644 --- a/.github/workflows/api.yml +++ b/.github/workflows/api.yml @@ -20,7 +20,7 @@ jobs: - name: Checkout code uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: node-version-file: .nvmrc cache: "npm" diff --git a/.github/workflows/build-server-image.yml b/.github/workflows/build-server-image.yml index dad4a5b3015..b0ca2bcdd2c 100644 --- a/.github/workflows/build-server-image.yml +++ b/.github/workflows/build-server-image.yml @@ -33,8 +33,8 @@ jobs: - name: buildenv/docker-login uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: - username: ${{ secrets.DOCKERHUB_DEV_USERNAME }} - password: ${{ secrets.DOCKERHUB_DEV_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: buildenv/build uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0 @@ -44,16 +44,16 @@ jobs: load: true push: false pull: false - tags: mattermostdevelopment/mattermost-build-server:test + tags: mattermost/mattermost-build-server:test - name: buildenv/test run: | - docker run --rm mattermostdevelopment/mattermost-build-server:test /bin/sh -c "go version && node --version" + docker run --rm mattermost/mattermost-build-server:test /bin/sh -c "go version && node --version" - name: buildenv/calculate-golang-version id: go run: | - GO_VERSION=$(docker run --rm mattermostdevelopment/mattermost-build-server:test go version | awk '{print $3}' | sed 's/go//') + GO_VERSION=$(docker run --rm mattermost/mattermost-build-server:test go version | awk '{print $3}' | sed 's/go//') echo "GO_VERSION=${GO_VERSION}" >> "${GITHUB_OUTPUT}" - name: buildenv/push @@ -65,7 +65,7 @@ jobs: load: false push: true pull: true - tags: mattermostdevelopment/mattermost-build-server:${{ steps.go.outputs.GO_VERSION }} + tags: mattermost/mattermost-build-server:${{ steps.go.outputs.GO_VERSION }} build-image-fips: runs-on: ubuntu-22.04 @@ -79,8 +79,8 @@ jobs: - name: buildenv/docker-login uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 with: - username: ${{ secrets.DOCKERHUB_DEV_USERNAME }} - password: ${{ secrets.DOCKERHUB_DEV_TOKEN }} + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: buildenv/build uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0 @@ -90,16 +90,16 @@ jobs: load: true push: false pull: false - tags: mattermostdevelopment/mattermost-build-server-fips:test + tags: mattermost/mattermost-build-server-fips:test - name: buildenv/test run: | - docker run --rm --entrypoint bash mattermostdevelopment/mattermost-build-server-fips:test -c "go version && node --version" + docker run --rm --entrypoint bash mattermost/mattermost-build-server-fips:test -c "go version && node --version" - name: buildenv/calculate-golang-version id: go run: | - GO_VERSION=$(docker run --rm --entrypoint bash mattermostdevelopment/mattermost-build-server-fips:test -c "go version" | awk '{print $3}' | sed 's/go//') + GO_VERSION=$(docker run --rm --entrypoint bash mattermost/mattermost-build-server-fips:test -c "go version" | awk '{print $3}' | sed 's/go//') echo "GO_VERSION=${GO_VERSION}" >> "${GITHUB_OUTPUT}" - name: buildenv/push @@ -111,4 +111,4 @@ jobs: load: false push: true pull: true - tags: mattermostdevelopment/mattermost-build-server-fips:${{ steps.go.outputs.GO_VERSION }} + tags: mattermost/mattermost-build-server-fips:${{ steps.go.outputs.GO_VERSION }} diff --git a/.github/workflows/e2e-tests-ci-template.yml b/.github/workflows/e2e-tests-ci-template.yml index 7b953976a4b..36ecdf64e21 100644 --- a/.github/workflows/e2e-tests-ci-template.yml +++ b/.github/workflows/e2e-tests-ci-template.yml @@ -133,7 +133,7 @@ jobs: fetch-depth: 0 - name: ci/setup-node if: "${{ inputs.run_preflight_checks }}" - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 id: setup_node with: node-version-file: ".nvmrc" @@ -164,7 +164,7 @@ jobs: fetch-depth: 0 - name: ci/setup-node if: "${{ inputs.run_preflight_checks }}" - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 id: setup_node with: node-version-file: ".nvmrc" @@ -246,7 +246,7 @@ jobs: ref: ${{ inputs.commit_sha }} fetch-depth: 0 - name: ci/setup-node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 id: setup_node with: node-version-file: ".nvmrc" @@ -333,7 +333,7 @@ jobs: ln -sfn /usr/local/opt/docker-compose/bin/docker-compose ~/.docker/cli-plugins/docker-compose sudo ln -sf $HOME/.colima/default/docker.sock /var/run/docker.sock - name: ci/setup-node - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 id: setup_node with: node-version-file: ".nvmrc" @@ -412,7 +412,7 @@ jobs: e2e-tests/${{ inputs.TEST }}/results/ - name: ci/setup-node if: "${{ inputs.enable_reporting }}" - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 id: setup_node with: node-version-file: ".nvmrc" diff --git a/.github/workflows/mmctl-test-template.yml b/.github/workflows/mmctl-test-template.yml index cf2ebb8d54d..18b3bb96d96 100644 --- a/.github/workflows/mmctl-test-template.yml +++ b/.github/workflows/mmctl-test-template.yml @@ -46,7 +46,7 @@ jobs: echo "BUILD_IMAGE=mattermost/mattermost-build-server-fips:${{ inputs.go-version }}" >> "${GITHUB_OUTPUT}" echo "LOG_ARTIFACT_NAME=${{ inputs.logsartifact }}-fips" >> "${GITHUB_OUTPUT}" else - echo "BUILD_IMAGE=mattermostdevelopment/mattermost-build-server:${{ inputs.go-version }}" >> "${GITHUB_OUTPUT}" + echo "BUILD_IMAGE=mattermost/mattermost-build-server:${{ inputs.go-version }}" >> "${GITHUB_OUTPUT}" echo "LOG_ARTIFACT_NAME=${{ inputs.logsartifact }}" >> "${GITHUB_OUTPUT}" fi diff --git a/.github/workflows/server-ci.yml b/.github/workflows/server-ci.yml index 6e9669e8224..919f697f8d6 100644 --- a/.github/workflows/server-ci.yml +++ b/.github/workflows/server-ci.yml @@ -40,7 +40,7 @@ jobs: name: Check mocks needs: go runs-on: ubuntu-22.04 - container: mattermostdevelopment/mattermost-build-server:${{ needs.go.outputs.version }} + container: mattermost/mattermost-build-server:${{ needs.go.outputs.version }} defaults: run: working-directory: server @@ -57,7 +57,7 @@ jobs: name: Check go mod tidy needs: go runs-on: ubuntu-22.04 - container: mattermostdevelopment/mattermost-build-server:${{ needs.go.outputs.version }} + container: mattermost/mattermost-build-server:${{ needs.go.outputs.version }} defaults: run: working-directory: server @@ -74,7 +74,7 @@ jobs: name: check-style needs: go runs-on: ubuntu-22.04 - container: mattermostdevelopment/mattermost-build-server:${{ needs.go.outputs.version }} + container: mattermost/mattermost-build-server:${{ needs.go.outputs.version }} defaults: run: working-directory: server @@ -91,7 +91,7 @@ jobs: name: Check serialization methods for hot structs needs: go runs-on: ubuntu-22.04 - container: mattermostdevelopment/mattermost-build-server:${{ needs.go.outputs.version }} + container: mattermost/mattermost-build-server:${{ needs.go.outputs.version }} defaults: run: working-directory: server @@ -108,7 +108,7 @@ jobs: name: Vet API needs: go runs-on: ubuntu-22.04 - container: mattermostdevelopment/mattermost-build-server:${{ needs.go.outputs.version }} + container: mattermost/mattermost-build-server:${{ needs.go.outputs.version }} defaults: run: working-directory: server @@ -123,7 +123,7 @@ jobs: name: Check migration files needs: go runs-on: ubuntu-22.04 - container: mattermostdevelopment/mattermost-build-server:${{ needs.go.outputs.version }} + container: mattermost/mattermost-build-server:${{ needs.go.outputs.version }} defaults: run: working-directory: server @@ -138,7 +138,7 @@ jobs: name: Generate email templates needs: go runs-on: ubuntu-22.04 - container: mattermostdevelopment/mattermost-build-server:${{ needs.go.outputs.version }} + container: mattermost/mattermost-build-server:${{ needs.go.outputs.version }} defaults: run: working-directory: server @@ -155,7 +155,7 @@ jobs: name: Check store layers needs: go runs-on: ubuntu-22.04 - container: mattermostdevelopment/mattermost-build-server:${{ needs.go.outputs.version }} + container: mattermost/mattermost-build-server:${{ needs.go.outputs.version }} defaults: run: working-directory: server @@ -172,7 +172,7 @@ jobs: name: Check mmctl docs needs: go runs-on: ubuntu-22.04 - container: mattermostdevelopment/mattermost-build-server:${{ needs.go.outputs.version }} + container: mattermost/mattermost-build-server:${{ needs.go.outputs.version }} defaults: run: working-directory: server @@ -271,7 +271,7 @@ jobs: name: Build mattermost server app needs: go runs-on: ubuntu-22.04 - container: mattermostdevelopment/mattermost-build-server:${{ needs.go.outputs.version }} + container: mattermost/mattermost-build-server:${{ needs.go.outputs.version }} defaults: run: working-directory: server @@ -282,6 +282,12 @@ jobs: steps: - name: Checkout mattermost project uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: ci/setup-node + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + with: + node-version-file: ".nvmrc" + cache: "npm" + cache-dependency-path: "webapp/package-lock.json" - name: Run setup-go-work run: make setup-go-work - name: Build diff --git a/.github/workflows/server-test-template.yml b/.github/workflows/server-test-template.yml index 838957adf86..9ab55c3e187 100644 --- a/.github/workflows/server-test-template.yml +++ b/.github/workflows/server-test-template.yml @@ -59,7 +59,7 @@ jobs: echo "BUILD_IMAGE=mattermost/mattermost-build-server-fips:${{ inputs.go-version }}" >> "${GITHUB_OUTPUT}" echo "LOG_ARTIFACT_NAME=${{ inputs.logsartifact }}-fips" >> "${GITHUB_OUTPUT}" else - echo "BUILD_IMAGE=mattermostdevelopment/mattermost-build-server:${{ inputs.go-version }}" >> "${GITHUB_OUTPUT}" + echo "BUILD_IMAGE=mattermost/mattermost-build-server:${{ inputs.go-version }}" >> "${GITHUB_OUTPUT}" echo "LOG_ARTIFACT_NAME=${{ inputs.logsartifact }}" >> "${GITHUB_OUTPUT}" fi diff --git a/.github/workflows/webapp-ci.yml b/.github/workflows/webapp-ci.yml index 67bb02708a8..6bd49d3f834 100644 --- a/.github/workflows/webapp-ci.yml +++ b/.github/workflows/webapp-ci.yml @@ -41,18 +41,10 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: ci/setup uses: ./.github/actions/webapp-setup - - name: ci/lint + - name: ci/i18n-extract working-directory: webapp/channels run: | - cp src/i18n/en.json /tmp/en.json - mkdir -p /tmp/fake-mobile-dir/assets/base/i18n/ - echo '{}' > /tmp/fake-mobile-dir/assets/base/i18n/en.json - npm run mmjstool -- i18n extract-webapp --webapp-dir ./src --mobile-dir /tmp/fake-mobile-dir - diff /tmp/en.json src/i18n/en.json - # Address weblate behavior which does not remove whole translation item when translation string is set to empty - npm run mmjstool -- i18n clean-empty --webapp-dir ./src --mobile-dir /tmp/fake-mobile-dir --check - npm run mmjstool -- i18n check-empty-src --webapp-dir ./src --mobile-dir /tmp/fake-mobile-dir - rm -rf tmp + npm run i18n-extract:check check-types: needs: check-lint diff --git a/.gitignore b/.gitignore index 473d44b7b37..b4e6be36708 100644 --- a/.gitignore +++ b/.gitignore @@ -161,5 +161,5 @@ docker-compose.override.yaml .env **/CLAUDE.local.md -CLAUDE.md +**/CLAUDE.md .cursorrules diff --git a/.nvmrc b/.nvmrc index a3597ecbd10..f88da62e246 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -20.11 +24.11 diff --git a/NOTICE.txt b/NOTICE.txt index 6a65d0d5165..a44174673e7 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -9695,41 +9695,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ---- - -## oov/psd - -This product contains 'psd' by oov. - -A PSD/PSB file reader for go - -* HOMEPAGE: - * https://github.com/oov/psd - -* LICENSE: MIT - -MIT License - -Copyright (c) 2016 oov - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - --- ## opensearch-project/opensearch-go @@ -10896,6 +10861,21 @@ Internationalize React apps. This library provides React components and an API t +--- + +## react-intl + +This product contains 'react-intl' by Eric Ferraiuolo. + +Internationalize React apps. This library provides React components and an API to format dates, numbers, and strings, including pluralization and handling translations. + +* HOMEPAGE: + * https://formatjs.github.io/docs/react-intl + +* LICENSE: BSD-3-Clause + + + --- ## react-is @@ -13051,5 +13031,3 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - diff --git a/api/Makefile b/api/Makefile index f59c5d7991c..5328a452f35 100644 --- a/api/Makefile +++ b/api/Makefile @@ -20,6 +20,8 @@ build-v4: node_modules playbooks @cat $(V4_SRC)/posts.yaml >> $(V4_YAML) @cat $(V4_SRC)/preferences.yaml >> $(V4_YAML) @cat $(V4_SRC)/files.yaml >> $(V4_YAML) + @cat $(V4_SRC)/recaps.yaml >> $(V4_YAML) + @cat $(V4_SRC)/ai.yaml >> $(V4_YAML) @cat $(V4_SRC)/uploads.yaml >> $(V4_YAML) @cat $(V4_SRC)/jobs.yaml >> $(V4_YAML) @cat $(V4_SRC)/system.yaml >> $(V4_YAML) diff --git a/api/v4/source/ai.yaml b/api/v4/source/ai.yaml new file mode 100644 index 00000000000..183bcbbf189 --- /dev/null +++ b/api/v4/source/ai.yaml @@ -0,0 +1,54 @@ + /api/v4/ai/agents: + get: + tags: + - ai + summary: Get available AI agents + description: > + Retrieve all available AI agents from the AI plugin's bridge API. + If a user ID is provided, only agents accessible to that user are returned. + + ##### Permissions + + Must be authenticated. + + __Minimum server version__: 11.2 + operationId: GetAIAgents + responses: + "200": + description: AI agents retrieved successfully + content: + application/json: + schema: + $ref: "#/components/schemas/AgentsResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalServerError" + /api/v4/ai/services: + get: + tags: + - ai + summary: Get available AI services + description: > + Retrieve all available AI services from the AI plugin's bridge API. + If a user ID is provided, only services accessible to that user + (via their permitted bots) are returned. + + ##### Permissions + + Must be authenticated. + + __Minimum server version__: 11.2 + operationId: GetAIServices + responses: + "200": + description: AI services retrieved successfully + content: + application/json: + schema: + $ref: "#/components/schemas/ServicesResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalServerError" + diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index 9419724ca7e..37bc4d7f5b0 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -3704,7 +3704,7 @@ components: type: array description: list of users participating in this thread. only includes IDs unless 'extended' was set to 'true' items: - $ref: "#/components/schemas/Post" + $ref: "#/components/schemas/User" post: $ref: "#/components/schemas/Post" RelationalIntegrityCheckData: @@ -4633,6 +4633,83 @@ components: active: type: boolean description: The active status of the policy. + Recap: + type: object + properties: + id: + type: string + description: Unique identifier for the recap + user_id: + type: string + description: ID of the user who created the recap + title: + type: string + description: AI-generated title for the recap (max 5 words) + create_at: + type: integer + format: int64 + description: The time in milliseconds the recap was created + update_at: + type: integer + format: int64 + description: The time in milliseconds the recap was last updated + delete_at: + type: integer + format: int64 + description: The time in milliseconds the recap was deleted + read_at: + type: integer + format: int64 + description: The time in milliseconds the recap was marked as read + total_message_count: + type: integer + description: Total number of messages summarized across all channels + status: + type: string + enum: [pending, processing, completed, failed] + description: Current status of the recap job + bot_id: + type: string + description: ID of the AI agent/bot used to generate this recap + channels: + type: array + items: + $ref: "#/components/schemas/RecapChannel" + description: List of channel summaries included in this recap + RecapChannel: + type: object + properties: + id: + type: string + description: Unique identifier for the recap channel + recap_id: + type: string + description: ID of the parent recap + channel_id: + type: string + description: ID of the channel that was summarized + channel_name: + type: string + description: Display name of the channel + highlights: + type: array + items: + type: string + description: Key discussion points and important information from the channel + action_items: + type: array + items: + type: string + description: Tasks, todos, and action items mentioned in the channel + source_post_ids: + type: array + items: + type: string + description: IDs of the posts used to generate this summary + create_at: + type: integer + format: int64 + description: The time in milliseconds the recap channel was created externalDocs: description: Find out more about Mattermost url: 'https://about.mattermost.com' diff --git a/api/v4/source/introduction.yaml b/api/v4/source/introduction.yaml index 45ac7e51ff9..f3ea1c2ebd8 100644 --- a/api/v4/source/introduction.yaml +++ b/api/v4/source/introduction.yaml @@ -462,8 +462,10 @@ tags: description: Endpoints related to metrics, including the Client Performance Monitoring feature. - name: audit_logs description: Endpoints for managing audit log certificates and configuration. - - name: ai - description: Endpoints for interacting with AI agents and services. + - name: recaps + description: Endpoints for creating and managing AI-powered channel recaps that summarize unread messages. + - name: agents + description: Endpoints for interacting with AI agents and LLM services. servers: - url: "{your-mattermost-url}" variables: diff --git a/api/v4/source/recaps.yaml b/api/v4/source/recaps.yaml new file mode 100644 index 00000000000..85419629a26 --- /dev/null +++ b/api/v4/source/recaps.yaml @@ -0,0 +1,240 @@ + "/api/v4/recaps": + post: + tags: + - recaps + - ai + summary: Create a channel recap + description: > + Create a new AI-powered recap for the specified channels. The recap will + summarize unread messages in the selected channels, extracting highlights + and action items. This creates a background job that processes the recap + asynchronously. The recap is created for the authenticated user. + + ##### Permissions + + Must be authenticated. User must be a member of all specified channels. + + __Minimum server version__: 11.2 + operationId: CreateRecap + requestBody: + content: + application/json: + schema: + type: object + required: + - channel_ids + - title + - agent_id + properties: + title: + type: string + description: Title for the recap + channel_ids: + type: array + items: + type: string + description: List of channel IDs to include in the recap + minItems: 1 + agent_id: + type: string + description: ID of the AI agent to use for generating the recap + description: Recap creation request + required: true + responses: + "201": + description: Recap creation successful. The recap will be processed asynchronously. + content: + application/json: + schema: + $ref: "#/components/schemas/Recap" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + get: + tags: + - recaps + - ai + summary: Get current user's recaps + description: > + Get a paginated list of recaps created by the authenticated user. + + ##### Permissions + + Must be authenticated. + + __Minimum server version__: 11.2 + operationId: GetRecapsForUser + parameters: + - name: page + in: query + description: The page to select. + schema: + type: integer + default: 0 + - name: per_page + in: query + description: The number of recaps per page. + schema: + type: integer + default: 60 + responses: + "200": + description: Recaps retrieval successful + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Recap" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "/api/v4/recaps/{recap_id}": + get: + tags: + - recaps + - ai + summary: Get a specific recap + description: > + Get a recap by its ID, including all channel summaries. Only the authenticated + user who created the recap can retrieve it. + + ##### Permissions + + Must be authenticated. Can only retrieve recaps created by the current user. + + __Minimum server version__: 11.2 + operationId: GetRecap + parameters: + - name: recap_id + in: path + description: Recap GUID + required: true + schema: + type: string + responses: + "200": + description: Recap retrieval successful + content: + application/json: + schema: + $ref: "#/components/schemas/Recap" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: + - recaps + - ai + summary: Delete a recap + description: > + Delete a recap by its ID. Only the authenticated user who created the recap + can delete it. + + ##### Permissions + + Must be authenticated. Can only delete recaps created by the current user. + + __Minimum server version__: 11.2 + operationId: DeleteRecap + parameters: + - name: recap_id + in: path + description: Recap GUID + required: true + schema: + type: string + responses: + "200": + description: Recap deletion successful + content: + application/json: + schema: + $ref: "#/components/schemas/StatusOK" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "/api/v4/recaps/{recap_id}/read": + post: + tags: + - recaps + - ai + summary: Mark a recap as read + description: > + Mark a recap as read by the authenticated user. This updates the recap's + read status and timestamp. + + ##### Permissions + + Must be authenticated. Can only mark recaps created by the current user as read. + + __Minimum server version__: 11.2 + operationId: MarkRecapAsRead + parameters: + - name: recap_id + in: path + description: Recap GUID + required: true + schema: + type: string + responses: + "200": + description: Recap marked as read successfully + content: + application/json: + schema: + $ref: "#/components/schemas/Recap" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "/api/v4/recaps/{recap_id}/regenerate": + post: + tags: + - recaps + - ai + summary: Regenerate a recap + description: > + Regenerate a recap by its ID. This creates a new background job to + regenerate the AI-powered recap with the latest messages from the + specified channels. + + ##### Permissions + + Must be authenticated. Can only regenerate recaps created by the current user. + + __Minimum server version__: 11.2 + operationId: RegenerateRecap + parameters: + - name: recap_id + in: path + description: Recap GUID + required: true + schema: + type: string + responses: + "200": + description: Recap regeneration initiated successfully + content: + application/json: + schema: + $ref: "#/components/schemas/Recap" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + diff --git a/e2e-tests/.ci/server.generate.sh b/e2e-tests/.ci/server.generate.sh index 3c733832da6..103d849c29f 100755 --- a/e2e-tests/.ci/server.generate.sh +++ b/e2e-tests/.ci/server.generate.sh @@ -260,7 +260,7 @@ $(if mme2e_is_token_in_list "webhook-interactions" "$ENABLED_DOCKER_SERVICES"; t # shellcheck disable=SC2016 echo ' webhook-interactions: - image: mattermostdevelopment/mirrored-node:${NODE_VERSION_REQUIRED} + image: node:${NODE_VERSION_REQUIRED} command: sh -c "npm install --global --legacy-peer-deps && exec node webhook_serve.js" healthcheck: test: ["CMD", "curl", "-s", "-o/dev/null", "127.0.0.1:3000"] @@ -275,11 +275,21 @@ $(if mme2e_is_token_in_list "webhook-interactions" "$ENABLED_DOCKER_SERVICES"; t fi) $(if mme2e_is_token_in_list "playwright" "$ENABLED_DOCKER_SERVICES"; then + # shellcheck disable=SC2016 echo ' playwright: - image: mcr.microsoft.com/playwright:v1.56.0-noble + image: mcr.microsoft.com/playwright:v1.57.0-noble entrypoint: ["/bin/bash", "-c"] - command: ["until [ -f /var/run/mm_terminate ]; do sleep 5; done"] + command: + - | + # Install Node.js based on .nvmrc + NODE_VERSION=$$(cat /mattermost/.nvmrc) + echo "Installing Node.js $${NODE_VERSION}..." + curl -fsSL https://deb.nodesource.com/setup_$${NODE_VERSION%%.*}.x | bash - + apt-get install -y nodejs + echo "Node.js version: $$(node --version)" + # Wait for termination signal + until [ -f /var/run/mm_terminate ]; do sleep 5; done env_file: - "./.env.playwright" environment: diff --git a/e2e-tests/cypress/package-lock.json b/e2e-tests/cypress/package-lock.json index 08aee13e696..4c0d3d484ee 100644 --- a/e2e-tests/cypress/package-lock.json +++ b/e2e-tests/cypress/package-lock.json @@ -74,7 +74,6 @@ "mochawesome-merge": "4.4.1", "mochawesome-report-generator": "6.2.0", "moment-timezone": "0.6.0", - "mysql": "2.18.1", "path": "0.12.7", "pdf-parse": "1.1.1", "pg": "8.16.3", @@ -93,7 +92,6 @@ "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" @@ -1071,7 +1069,6 @@ "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -1114,6 +1111,7 @@ "integrity": "sha512-N4ntErOlKvcbTt01rr5wj3y55xnIdx1ymrfIr8C2WnM1Y9glFgWaGDEULJIazOX3XM9NRzhfJ6zZnQ1sBNWU+w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", "eslint-visitor-keys": "^2.1.0", @@ -1150,7 +1148,6 @@ "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.28.0", "@babel/types": "^7.28.0", @@ -1168,7 +1165,6 @@ "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", @@ -1186,7 +1182,6 @@ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -1197,7 +1192,6 @@ "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" @@ -1212,7 +1206,6 @@ "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", @@ -1231,7 +1224,6 @@ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -1252,7 +1244,6 @@ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -1263,7 +1254,6 @@ "integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.2" @@ -1278,7 +1268,6 @@ "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.28.0" }, @@ -1305,7 +1294,6 @@ "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", @@ -1321,7 +1309,6 @@ "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", @@ -1341,7 +1328,6 @@ "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" @@ -1813,7 +1799,6 @@ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" @@ -1825,7 +1810,6 @@ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.0.0" } @@ -1836,7 +1820,6 @@ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" @@ -1847,8 +1830,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.30", @@ -1856,7 +1838,6 @@ "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -1884,6 +1865,7 @@ "integrity": "sha512-2795KUkp2EkuJ9NVohPkJmrgKunt6OZiLyo8zUoIWPJjxQ0upjiWJz/KenABx38v8+QfpSEN8tZSBN3lsZCueg==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "typescript": "^4.3.0 || ^5.0.0" }, @@ -2866,7 +2848,6 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -2878,7 +2859,6 @@ "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint": "*", "@types/estree": "*" @@ -3179,6 +3159,7 @@ "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.39.1", "@typescript-eslint/types": "8.39.1", @@ -3397,7 +3378,6 @@ "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.13.2", "@webassemblyjs/helper-wasm-bytecode": "1.13.2" @@ -3408,24 +3388,21 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { "version": "1.13.2", @@ -3433,7 +3410,6 @@ "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/floating-point-hex-parser": "1.13.2", "@webassemblyjs/helper-api-error": "1.13.2", @@ -3445,8 +3421,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { "version": "1.14.1", @@ -3454,7 +3429,6 @@ "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -3468,7 +3442,6 @@ "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@xtuc/ieee754": "^1.2.0" } @@ -3479,7 +3452,6 @@ "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@xtuc/long": "4.2.2" } @@ -3489,8 +3461,7 @@ "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { "version": "1.14.1", @@ -3498,7 +3469,6 @@ "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -3516,7 +3486,6 @@ "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-wasm-bytecode": "1.13.2", @@ -3531,7 +3500,6 @@ "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-buffer": "1.14.1", @@ -3545,7 +3513,6 @@ "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@webassemblyjs/helper-api-error": "1.13.2", @@ -3561,7 +3528,6 @@ "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" @@ -3572,16 +3538,14 @@ "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "dev": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/accepts": { "version": "2.0.0", @@ -3603,6 +3567,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3616,7 +3581,6 @@ "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" }, @@ -3671,7 +3635,6 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -3690,7 +3653,6 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3707,8 +3669,7 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/ally.js": { "version": "1.4.1", @@ -4198,16 +4159,6 @@ "tweetnacl": "^0.14.3" } }, - "node_modules/bignumber.js": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.0.tgz", - "integrity": "sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, "node_modules/blob-util": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz", @@ -4340,8 +4291,7 @@ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/byte-length": { "version": "1.0.2", @@ -4462,8 +4412,7 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "CC-BY-4.0", - "peer": true + "license": "CC-BY-4.0" }, "node_modules/caseless": { "version": "0.12.0", @@ -4558,7 +4507,6 @@ "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.0" } @@ -4848,8 +4796,7 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cookie": { "version": "0.7.2", @@ -4871,13 +4818,6 @@ "node": ">=6.6.0" } }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, - "license": "MIT" - }, "node_modules/cross-env": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.0.0.tgz", @@ -4935,6 +4875,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@cypress/request": "^3.0.9", "@cypress/xvfb": "^1.2.4", @@ -5423,8 +5364,7 @@ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.200.tgz", "integrity": "sha512-rFCxROw7aOe4uPTfIAx+rXv9cEcGx+buAF4npnhtTqCJk5KDFRnh3+KYj7rdVh6lsFt5/aPs+Irj9rZ33WMA7w==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/emoji-regex": { "version": "9.2.2", @@ -5473,6 +5413,7 @@ "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" @@ -5603,8 +5544,7 @@ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/es-object-atoms": { "version": "1.1.1", @@ -5702,6 +5642,7 @@ "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -5903,6 +5844,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6535,8 +6477,7 @@ "url": "https://opencollective.com/fastify" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/fast-xml-parser": { "version": "5.2.5", @@ -6916,7 +6857,6 @@ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -7083,8 +7023,7 @@ "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", "dev": true, - "license": "BSD-2-Clause", - "peer": true + "license": "BSD-2-Clause" }, "node_modules/glob/node_modules/minimatch": { "version": "10.0.3", @@ -7985,13 +7924,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -8046,7 +7978,6 @@ "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "merge-stream": "^2.0.0", @@ -8062,7 +7993,6 @@ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -8079,6 +8009,7 @@ "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -8116,7 +8047,6 @@ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jsesc": "bin/jsesc" }, @@ -8136,8 +8066,7 @@ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/json-schema": { "version": "0.4.0", @@ -8173,7 +8102,6 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "json5": "lib/cli.js" }, @@ -8449,7 +8377,6 @@ "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.11.5" } @@ -8652,7 +8579,6 @@ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "yallist": "^3.0.2" } @@ -8874,6 +8800,7 @@ "integrity": "sha512-5EK+Cty6KheMS/YLPPMJC64g5V61gIR25KsRItHw6x4hEKT6Njp1n9LOlH4gpevuwMVS66SXaBBpg+RWZkza4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "browser-stdout": "^1.3.1", "chokidar": "^4.0.1", @@ -9372,29 +9299,6 @@ "dev": true, "license": "MIT" }, - "node_modules/mysql": { - "version": "2.18.1", - "resolved": "https://registry.npmjs.org/mysql/-/mysql-2.18.1.tgz", - "integrity": "sha512-Bca+gk2YWmqp2Uf6k5NFEurwY/0td0cpebAucFpY/3jhrwrVGuxU2uQFCHjU19SJfje0yQvi+rVWdq78hR5lig==", - "dev": true, - "license": "MIT", - "dependencies": { - "bignumber.js": "9.0.0", - "readable-stream": "2.3.7", - "safe-buffer": "5.1.2", - "sqlstring": "2.3.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mysql/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -9417,8 +9321,7 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/node-ensure": { "version": "0.0.0", @@ -9432,8 +9335,7 @@ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/notp": { "version": "2.0.3", @@ -10264,13 +10166,6 @@ "node": ">= 0.6.0" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, - "license": "MIT" - }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -10441,29 +10336,6 @@ "dev": true, "license": "MIT" }, - "node_modules/readable-stream": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", - "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -10619,7 +10491,6 @@ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10852,7 +10723,6 @@ "integrity": "sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -10891,7 +10761,6 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -10904,8 +10773,7 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/semver": { "version": "6.3.1", @@ -10972,6 +10840,7 @@ "integrity": "sha512-b0IrY3b1gVMsWvJppCf19g1p3JSnS0hQi6xu4Hi40CIhf0Lx8pQHcvBL+xunShpmOiQzg1NOia812NAWdSaShw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@servie/events": "^1.0.0", "byte-length": "^1.0.2", @@ -11229,7 +11098,6 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11240,7 +11108,6 @@ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -11256,16 +11123,6 @@ "node": ">= 10.x" } }, - "node_modules/sqlstring": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.1.tgz", - "integrity": "sha512-ooAzh/7dxIG5+uDik1z/Rd1vli0+38izZhGzSa34FwR7IbelPWCCKSNIl8jlL/F7ERvy8CB2jNeM1E9i9mXMAQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/sshpk": { "version": "1.18.0", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", @@ -11659,7 +11516,6 @@ "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.14.0", @@ -11679,7 +11535,6 @@ "integrity": "sha512-vkZjpUjb6OMS7dhV+tILUW6BhpDR7P2L/aQSAv+Uwk+m8KATX9EccViHTJR2qDtACKPIYndLGCyl3FMo+r2LMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -11714,8 +11569,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/thirty-two": { "version": "0.0.2", @@ -12038,6 +11892,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12106,6 +11961,7 @@ "integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.41.0", "@typescript-eslint/types": "8.41.0", @@ -12394,7 +12250,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -12513,7 +12368,6 @@ "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -12528,7 +12382,6 @@ "integrity": "sha512-rHY3vHXRbkSfhG6fH8zYQdth/BtDgXXuR2pHF++1f/EBkI8zkgM5XWfsC3BvOoW9pr1CvZ1qQCxhCEsbNgT50g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -12578,7 +12431,6 @@ "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.13.0" } @@ -12589,7 +12441,6 @@ "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -12604,7 +12455,6 @@ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -12615,7 +12465,6 @@ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -12629,7 +12478,6 @@ "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -12906,8 +12754,7 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/yargs": { "version": "17.7.2", diff --git a/e2e-tests/cypress/package.json b/e2e-tests/cypress/package.json index b69a094c7a0..c6676f388f1 100644 --- a/e2e-tests/cypress/package.json +++ b/e2e-tests/cypress/package.json @@ -69,7 +69,6 @@ "mochawesome-merge": "4.4.1", "mochawesome-report-generator": "6.2.0", "moment-timezone": "0.6.0", - "mysql": "2.18.1", "path": "0.12.7", "pdf-parse": "1.1.1", "pg": "8.16.3", diff --git a/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_popovers_spec.js b/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_popovers_spec.js index 27bbb892e77..e188633515d 100644 --- a/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_popovers_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/accessibility/accessibility_popovers_spec.js @@ -50,7 +50,7 @@ describe('Verify Accessibility Support in Popovers', () => { {ariaLabel: 'People & Body', header: 'People & Body'}, {ariaLabel: 'Animals & Nature', header: 'Animals & Nature'}, {ariaLabel: 'Food & Drink', header: 'Food & Drink'}, - {ariaLabel: 'Travel Places', header: 'Travel Places'}, + {ariaLabel: 'Travel & Places', header: 'Travel & Places'}, {ariaLabel: 'Activities', header: 'Activities'}, {ariaLabel: 'Objects', header: 'Objects'}, {ariaLabel: 'Symbols', header: 'Symbols'}, diff --git a/e2e-tests/cypress/tests/integration/channels/account_settings/profile/email_spec.ts b/e2e-tests/cypress/tests/integration/channels/account_settings/profile/email_spec.ts index 4339ae49753..3891822871d 100644 --- a/e2e-tests/cypress/tests/integration/channels/account_settings/profile/email_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/account_settings/profile/email_spec.ts @@ -85,7 +85,7 @@ describe('Profile > Profile Settings > Email', () => { cy.get('#primaryEmail').should('be.visible').click().blur(); // * Check that the correct error message is shown. - cy.get('#error_primaryEmail').should('be.visible').should('have.text', 'Please enter a valid email address'); + cy.get('#error_primaryEmail').should('be.visible').should('have.text', 'Please enter a valid email address.'); }); it('MM-T2067 email address already taken error', () => { diff --git a/e2e-tests/cypress/tests/integration/channels/archived_channel/join_archived_channel_spec.ts b/e2e-tests/cypress/tests/integration/channels/archived_channel/join_archived_channel_spec.ts index aa367eaac35..5a841df5a18 100644 --- a/e2e-tests/cypress/tests/integration/channels/archived_channel/join_archived_channel_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/archived_channel/join_archived_channel_spec.ts @@ -103,7 +103,7 @@ describe('Archived channels', () => { function verifyViewingArchivedChannel(channel) { // * Verify that we've switched to the correct channel and that the header contains the archived icon cy.get('#channelHeaderTitle').should('contain', channel.display_name); - cy.get('#channelHeaderInfo .icon__archive').should('be.visible'); + cy.findByTestId('channel-header-archive-icon').should('be.visible'); // * Verify that the channel is visible in the sidebar with the archived icon cy.get(`#sidebarItem_${channel.name}`).should('be.visible'). diff --git a/e2e-tests/cypress/tests/integration/channels/auth_sso/authentication_1_spec.ts b/e2e-tests/cypress/tests/integration/channels/auth_sso/authentication_1_spec.ts index 8f3b02e29a3..6f4061bb835 100644 --- a/e2e-tests/cypress/tests/integration/channels/auth_sso/authentication_1_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/auth_sso/authentication_1_spec.ts @@ -61,7 +61,7 @@ describe('Authentication', () => { cy.get('#input_name').clear().type(`test${getRandomId()}`); - cy.findByText('Create Account').click(); + cy.findByText('Create account').click(); // * Make sure account was created successfully and we are at the select team page cy.findByText('Teams you can join:', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible'); @@ -113,7 +113,7 @@ describe('Authentication', () => { cy.get('#input_name').clear().type(`test${getRandomId()}`); - cy.findByText('Create Account').click(); + cy.findByText('Create account').click(); // * Make sure account was not created successfully cy.get('.AlertBanner__title').scrollIntoView().should('be.visible'); @@ -146,7 +146,7 @@ describe('Authentication', () => { cy.get('#input_name').clear().type(username); - cy.findByText('Create Account').click(); + cy.findByText('Create account').click(); // * Make sure account was created successfully and we are on the team joining page cy.findByText('Teams you can join:', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible'); diff --git a/e2e-tests/cypress/tests/integration/channels/auth_sso/authentication_2_spec.ts b/e2e-tests/cypress/tests/integration/channels/auth_sso/authentication_2_spec.ts index 9a3d2064d56..7198829b4f8 100644 --- a/e2e-tests/cypress/tests/integration/channels/auth_sso/authentication_2_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/auth_sso/authentication_2_spec.ts @@ -61,14 +61,14 @@ describe('Authentication', () => { cy.get('#input_password-input').clear().type('less'); - cy.findByText('Create Account').click(); + cy.findByText('Create account').click(); // * Assert the error is what is expected; cy.findByText('Your password must be 7-72 characters long.').should('be.visible'); cy.get('#input_password-input').clear().type('greaterthan7'); - cy.findByText('Create Account').click(); + cy.findByText('Create account').click(); // * Assert that we are not shown an MFA screen and instead a Teams You Can join page cy.findByText('Teams you can join:', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible'); @@ -114,7 +114,7 @@ describe('Authentication', () => { ['NOLOWERCASE123!', 'noupppercase123!', 'NoNumber!', 'NoSymbol123'].forEach((option) => { cy.get('#input_password-input').clear().type(option); - cy.findByText('Create Account').click(); + cy.findByText('Create account').click(); // * Assert the error is what is expected; cy.findByText('Your password must be 5-72 characters long and include both lowercase and uppercase letters, numbers, and special characters.').should('be.visible'); diff --git a/e2e-tests/cypress/tests/integration/channels/auth_sso/authentication_4_spec.ts b/e2e-tests/cypress/tests/integration/channels/auth_sso/authentication_4_spec.ts index 51648920ab7..004d1895015 100644 --- a/e2e-tests/cypress/tests/integration/channels/auth_sso/authentication_4_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/auth_sso/authentication_4_spec.ts @@ -151,7 +151,7 @@ describe('Authentication', () => { ['1user', 'te', 'user#1', 'user!1'].forEach((option) => { cy.get('#input_name').clear().type(option); - cy.findByText('Create Account').click(); + cy.findByText('Create account').click(); // * Assert the error is what is expected; cy.get('.Input___error').scrollIntoView().should('be.visible'); @@ -183,7 +183,7 @@ describe('Authentication', () => { cy.get('#input_name').clear().type(`Test${getRandomId()}`); - cy.findByText('Create Account').click(); + cy.findByText('Create account').click(); // * Make sure account was created successfully and we are on the team joining page cy.findByText('Teams you can join:', {timeout: TIMEOUTS.ONE_MIN}).should('be.visible'); @@ -245,7 +245,7 @@ describe('Authentication', () => { cy.get('#input_name').clear().type(`Test${getRandomId()}`); - cy.findByText('Create Account').click(); + cy.findByText('Create account').click(); // * Make sure account was not created successfully cy.get('.AlertBanner__title').scrollIntoView().should('be.visible'); @@ -271,7 +271,7 @@ describe('Authentication', () => { cy.findByText('Copy invite link').click(); // # Input email, select member - cy.findByLabelText('Add or Invite People').type(`test-${getRandomId()}@mattermost.com{downarrow}{downarrow}{enter}`, {force: true}); + cy.findByLabelText('Invite People').type(`test-${getRandomId()}@mattermost.com{downarrow}{downarrow}{enter}`, {force: true}); // # Click invite members button cy.findByRole('button', {name: 'Invite'}).click({force: true}); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_input_fields_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_input_fields_spec.ts index 365b59a755f..09455c0e342 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_input_fields_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/accessibility/accessibility_input_fields_spec.ts @@ -49,7 +49,7 @@ describe('Verify Accessibility Support in different input fields', () => { // * Verify Accessibility Support in Add or Invite People input field cy.get('.users-emails-input__control').should('be.visible').within(() => { - cy.get('input').should('have.attr', 'aria-label', 'Add or Invite People').and('have.attr', 'aria-autocomplete', 'list'); + cy.get('input').should('have.attr', 'aria-label', 'Invite People').and('have.attr', 'aria-autocomplete', 'list'); cy.get('.users-emails-input__placeholder').should('have.text', 'Enter a name or email address'); }); @@ -58,7 +58,7 @@ describe('Verify Accessibility Support in different input fields', () => { // * Verify Accessibility Support in Invite People input field cy.get('.users-emails-input__control').should('be.visible').within(() => { - cy.get('input').should('have.attr', 'aria-label', 'Add or Invite People').and('have.attr', 'aria-autocomplete', 'list'); + cy.get('input').should('have.attr', 'aria-label', 'Invite People').and('have.attr', 'aria-autocomplete', 'list'); cy.get('.users-emails-input__placeholder').should('have.text', 'Enter a name or email address'); }); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/auth_sso/authentication_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/auth_sso/authentication_spec.ts index 00b914f9d26..22b2701697b 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/auth_sso/authentication_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/auth_sso/authentication_spec.ts @@ -58,7 +58,7 @@ describe('Authentication', () => { cy.get('#input_name').clear().type(`Test${getRandomId()}`); - cy.findByText('Create Account').click(); + cy.findByText('Create account').click(); // * Make sure account was not created successfully cy.get('.AlertBanner__title').scrollIntoView().should('be.visible'); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_identification_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_identification_spec.ts index 9db7e20f65e..e9823fd8a18 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_identification_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_identification_spec.ts @@ -78,13 +78,16 @@ describe('Guest Accounts', () => { // # Click "Save". cy.findByText('Save').click().wait(TIMEOUTS.ONE_SEC); - // # Get MFA secret + // # Visit a page to trigger MFA setup redirect, then complete MFA setup for admin + cy.visit('/'); + cy.url().should('include', 'mfa/setup'); cy.uiGetMFASecret(sysadmin.id).then((secret) => { adminMFASecret = secret; }); // # Navigate to Guest Access page. cy.visit('/admin_console/authentication/guest_access'); + cy.url().should('include', '/admin_console/authentication/guest_access'); // # Enable guest accounts. cy.findByTestId('GuestAccountsSettings.Enabletrue').check(); @@ -144,20 +147,20 @@ describe('Guest Accounts', () => { // # Create an account with Email and Password. cy.get('#input_name').type(username); cy.get('#input_password-input').type(username); - cy.findByText('Create Account').click(); + cy.findByText('Create account').click(); // * When MFA is enforced for Guest Access, guest user should be forced to configure MFA while creating an account. cy.url().should('include', 'mfa/setup'); - cy.get('#mfa').wait(TIMEOUTS.HALF_SEC).find('.col-sm-12').then((p) => { + cy.get('#mfa').wait(TIMEOUTS.HALF_SEC).find('p.col-sm-12 span').then((p) => { const secretp = p.text(); const secret = secretp.split(' ')[1]; const token = authenticator.generateToken(secret); - cy.get('#mfa').find('.form-control').type(token); - cy.get('#mfa').find('.btn.btn-primary').click(); + cy.findByPlaceholderText('MFA Code').type(token); + cy.findByText('Save').click(); cy.wait(TIMEOUTS.ONE_SEC); - cy.get('#mfa').find('.btn.btn-primary').click(); + cy.findByText('Okay').click(); }); cy.apiLogout(); }); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/member_invitation_ui_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/member_invitation_ui_spec.ts index 7fabe813bdd..62e8f1b401b 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/member_invitation_ui_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/member_invitation_ui_spec.ts @@ -106,7 +106,7 @@ describe('Guest Account - Member Invitation Flow', () => { cy.get('#input_email').type(email); cy.get('#input_name').type(username); cy.get('#input_password-input').type('Testing123'); - cy.findByText('Create Account').click(); + cy.findByText('Create account').click(); // * Verify if user is added to the invited team cy.uiGetLHSHeader().findByText(testTeam.display_name); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/integrations/incoming_webhook_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/integrations/incoming_webhook_spec.ts index 4d079673dad..2438b67c9f3 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/integrations/incoming_webhook_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/integrations/incoming_webhook_spec.ts @@ -65,8 +65,19 @@ describe('Incoming webhook', () => { cy.visit(`/${testTeam.name}/channels/${testChannel.name}`); + // # Post webhook and wait for attachment to render cy.postIncomingWebhook({url: incomingWebhook.url, data: payload}); + // # Verify the post appears in the channel with attachment + cy.getLastPost().within(() => { + cy.get('.attachment__body').should('be.visible').should('contain', 'Findme.'); + }); + + // # Explicitly wait to give Elasticsearch time to index before searching + // Using a longer wait time since Elasticsearch indexing can be slow in test environments + cy.wait(TIMEOUTS.THREE_SEC); + + // # Search for text in the attachment cy.uiGetSearchContainer().click(); cy.uiGetSearchBox(). wait(TIMEOUTS.HALF_SEC). diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/archived_channels_spec.js b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/archived_channels_spec.js index 33862500693..4e2645c3a21 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/archived_channels_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/archived_channels_spec.js @@ -14,17 +14,24 @@ import * as TIMEOUTS from '../../../../fixtures/timeouts'; describe('Archived channels', () => { let testChannel; + let testPrivateChannel; before(() => { cy.apiRequireLicense(); cy.apiInitSetup({ channelPrefix: {name: '000-archive', displayName: '000 Archive Test'}, - }).then(({channel}) => { + }).then(({channel, team}) => { testChannel = channel; - // # Archive the channel + // # Archive the public channel cy.apiDeleteChannel(testChannel.id); + + // # Create and archive a private channel with a prefix to ensure proper sorting + cy.apiCreateChannel(team.id, '000-private-archive', '000 Private Archive Test', 'P').then(({channel: privateChannel}) => { + testPrivateChannel = privateChannel; + cy.apiDeleteChannel(privateChannel.id); + }); }); }); @@ -83,4 +90,26 @@ describe('Archived channels', () => { expect(channel.delete_at).to.eq(0); }); }); + + it('display archive icon for public archived channels in channel list', () => { + // # Go to the channels list view + cy.visit('/admin_console/user_management/channels'); + + // * Verify the archived public channel is visible + cy.findByText(testChannel.display_name, {timeout: TIMEOUTS.ONE_MIN}).should('be.visible'); + + // * Verify the archive icon is displayed + cy.findByTestId(`${testChannel.name}-archive-icon`).should('be.visible'); + }); + + it('display archive-lock icon for private archived channels in channel list', () => { + // # Go to the channels list view + cy.visit('/admin_console/user_management/channels'); + + // * Verify the archived private channel is visible + cy.findByText(testPrivateChannel.display_name, {timeout: TIMEOUTS.ONE_MIN}).should('be.visible'); + + // * Verify the archive icon is displayed for private channel + cy.findByTestId(`${testPrivateChannel.name}-archive-icon`).should('be.visible'); + }); }); diff --git a/e2e-tests/cypress/tests/integration/channels/files_and_attachments/file_preview_image_spec.js b/e2e-tests/cypress/tests/integration/channels/files_and_attachments/file_preview_image_spec.js index 2dfa97b554f..6d392b11e9d 100644 --- a/e2e-tests/cypress/tests/integration/channels/files_and_attachments/file_preview_image_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/files_and_attachments/file_preview_image_spec.js @@ -92,18 +92,6 @@ describe('Upload Files - Image', () => { testImage(properties); }); - it('MM-T2264_6 - PSD', () => { - const properties = { - filePath: 'mm_file_testing/Images/PSD.psd', - fileName: 'PSD.psd', - originalWidth: 400, - originalHeight: 479, - mimeType: 'application/psd', - }; - - testImage(properties); - }); - it('MM-T2264_7 - WEBP', () => { const properties = { filePath: 'mm_file_testing/Images/WEBP.webp', diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/long_draft_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/long_draft_spec.js index 544fccad1ba..30f0ecf2e29 100644 --- a/e2e-tests/cypress/tests/integration/channels/messaging/long_draft_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/messaging/long_draft_spec.js @@ -81,6 +81,13 @@ describe('Messaging', () => { }); function writeLinesToPostTextBox(lines) { + let previousHeight; + + // Get the initial previous height from the alias + cy.get('@previousHeight').then((height) => { + previousHeight = height; + }); + Cypress._.forEach(lines, (line, i) => { // # Add the text cy.uiGetPostTextBox().type(line, {delay: TIMEOUTS.ONE_HUNDRED_MILLIS}).wait(TIMEOUTS.HALF_SEC); @@ -91,11 +98,14 @@ function writeLinesToPostTextBox(lines) { // * Verify new height cy.uiGetPostTextBox().invoke('height').then((height) => { + const currentHeight = parseInt(height, 10); + // * Verify previous height should be lower than the current height - cy.get('@previousHeight').should('be.lessThan', parseInt(height, 10)); + expect(previousHeight).to.be.lessThan(currentHeight); // # Store the current height as the previous height for the next loop - cy.wrap(parseInt(height, 10)).as('previousHeight'); + previousHeight = currentHeight; + cy.wrap(currentHeight).as('previousHeight'); }); } }); diff --git a/e2e-tests/cypress/tests/integration/channels/multi_team_and_dm/gm_header_spec.js b/e2e-tests/cypress/tests/integration/channels/multi_team_and_dm/gm_header_spec.js index 3bb9e501647..4a211564899 100644 --- a/e2e-tests/cypress/tests/integration/channels/multi_team_and_dm/gm_header_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/multi_team_and_dm/gm_header_spec.js @@ -61,7 +61,7 @@ describe('Multi-user group header', () => { cy.get('#editChannelHeaderModalLabel').should('be.visible').wait(TIMEOUTS.ONE_SEC); // # Add the header in the modal - cy.findByPlaceholderText('Edit the Channel Header...').should('be.visible').type(`${header}{enter}`); + cy.findByPlaceholderText('Enter the Channel Header').should('be.visible').type(`${header}{enter}`); // # Wait for modal to disappear cy.waitUntil(() => cy.get('#editChannelHeaderModalLabel').should('not.be.visible')); diff --git a/e2e-tests/cypress/tests/integration/channels/onboarding/existing_email_adress_spec.js b/e2e-tests/cypress/tests/integration/channels/onboarding/existing_email_adress_spec.js index 6849be8aaa5..c0c74a79c75 100644 --- a/e2e-tests/cypress/tests/integration/channels/onboarding/existing_email_adress_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/onboarding/existing_email_adress_spec.js @@ -32,7 +32,7 @@ function signupWithEmail(name, pw) { cy.get('#input_password-input').type(pw); // # Click on Create Account button - cy.findByText('Create Account').click(); + cy.findByText('Create account').click(); } describe('Cloud Onboarding', () => { diff --git a/e2e-tests/cypress/tests/integration/channels/onboarding/invalidate_pending_email_invitations_spec.js b/e2e-tests/cypress/tests/integration/channels/onboarding/invalidate_pending_email_invitations_spec.js index 62b82465766..11e8d02dbcb 100644 --- a/e2e-tests/cypress/tests/integration/channels/onboarding/invalidate_pending_email_invitations_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/onboarding/invalidate_pending_email_invitations_spec.js @@ -91,7 +91,7 @@ describe('Onboarding', () => { cy.get('#name').should('be.visible').type(usernameTwo); cy.get('#password').should('be.visible').type(password); - // # Attempt to create an account by clicking on the 'Create Account' button + // # Attempt to create an account by clicking on the 'Create account' button cy.get('#createAccountButton').click(); // * Ensure that since the invite was invalidated, the correct error message should be shown @@ -99,7 +99,7 @@ describe('Onboarding', () => { }); function inviteNewUser(email) { - cy.findByRole('textbox', {name: 'Add or Invite People'}). + cy.findByRole('textbox', {name: 'Invite People'}). typeWithForce(email).wait(TIMEOUTS.HALF_SEC). typeWithForce('{enter}'); cy.findByTestId('inviteButton').click(); diff --git a/e2e-tests/cypress/tests/integration/channels/onboarding/login_page_link_account_creation_spec.js b/e2e-tests/cypress/tests/integration/channels/onboarding/login_page_link_account_creation_spec.js index 19e79f25459..14f55a8f762 100644 --- a/e2e-tests/cypress/tests/integration/channels/onboarding/login_page_link_account_creation_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/onboarding/login_page_link_account_creation_spec.js @@ -71,7 +71,7 @@ describe('Onboarding', () => { cy.get('#input_email').should('be.focused').and('be.visible').type(email); cy.get('#input_name').should('be.visible').type(username); cy.get('#input_password-input').should('be.visible').type(password); - cy.findByText('Create Account').click(); + cy.findByText('Create account').click(); cy.findByText('You’re almost done!').should('be.visible'); diff --git a/e2e-tests/cypress/tests/integration/channels/onboarding/use_team_invite_link_to_sign_up_spec.js b/e2e-tests/cypress/tests/integration/channels/onboarding/use_team_invite_link_to_sign_up_spec.js index ea10dd6eb64..92eb39043d5 100644 --- a/e2e-tests/cypress/tests/integration/channels/onboarding/use_team_invite_link_to_sign_up_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/onboarding/use_team_invite_link_to_sign_up_spec.js @@ -71,8 +71,8 @@ describe('Onboarding', () => { cy.get('#input_name').should('be.visible').type(username); cy.get('#input_password-input').should('be.visible').type(password); - // # Attempt to create an account by clicking on the 'Create Account' button - cy.findByText('Create Account').click(); + // # Attempt to create an account by clicking on the 'Create account' button + cy.findByText('Create account').click(); cy.wait(TIMEOUTS.HALF_SEC); diff --git a/e2e-tests/cypress/tests/integration/channels/signin_authentication/forgot_password_spec.js b/e2e-tests/cypress/tests/integration/channels/signin_authentication/forgot_password_spec.js index c53c510b088..ea0037c59e3 100644 --- a/e2e-tests/cypress/tests/integration/channels/signin_authentication/forgot_password_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/signin_authentication/forgot_password_spec.js @@ -91,7 +91,7 @@ describe('Signin/Authentication', () => { cy.url().should('contain', '/login?extra=password_change'); // * Should show that the password is updated successfully - cy.get('.AlertBanner.success').should('be.visible').and('have.text', ' Password updated successfully'); + cy.get('.AlertBanner.success').should('be.visible').and('have.text', 'Password updated successfully'); // # Type email and new password, then click login button cy.get('#input_loginId').should('be.visible').type(testUser.username); diff --git a/e2e-tests/cypress/tests/integration/channels/signin_authentication/signup_spec.js b/e2e-tests/cypress/tests/integration/channels/signin_authentication/signup_spec.js index 71db9fb3fe4..7054d953939 100644 --- a/e2e-tests/cypress/tests/integration/channels/signin_authentication/signup_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/signin_authentication/signup_spec.js @@ -54,11 +54,6 @@ describe('Signup Email page', () => { }); it('should match elements, body', () => { - const { - PRIVACY_POLICY_LINK, - TERMS_OF_SERVICE_LINK, - } = FixedCloudConfig.SupportSettings; - // * Check elements in the body cy.get('.signup-body').should('be.visible'); cy.get('.header-logo-link').should('be.visible'); @@ -78,11 +73,15 @@ describe('Signup Email page', () => { cy.findByText('Your password must be 5-72 characters long.').should('be.visible'); cy.get('#saveSetting').scrollIntoView().should('be.visible'); - cy.get('#saveSetting').should('contain', 'Create Account'); + cy.get('#saveSetting').should('contain', 'Create account'); - cy.get('.signup-body-card-agreement').should('contain', `By proceeding to create your account and use ${config.TeamSettings.SiteName}, you agree to our Terms of Use and Privacy Policy. If you do not agree, you cannot use ${config.TeamSettings.SiteName}.`); - cy.get(`.signup-body-card-agreement > span > [href="${config.SupportSettings.TermsOfServiceLink || TERMS_OF_SERVICE_LINK}"]`).should('be.visible'); - cy.get(`.signup-body-card-agreement > span > [href="${config.SupportSettings.PrivacyPolicyLink || PRIVACY_POLICY_LINK}"]`).should('be.visible'); + // * Check newsletter subscription checkbox text and links + cy.findByText('I would like to receive Mattermost security updates via newsletter.').should('be.visible'); + cy.findByText(/By subscribing, I consent to receive emails from Mattermost with product updates, promotions, and company news\./).should('be.visible'); + cy.findByText(/I have read the/).parent().within(() => { + cy.findByRole('link', {name: 'Privacy Policy'}).should('be.visible').and('have.attr', 'href').and('include', 'mattermost.com/pl/privacy-policy/'); + cy.findByRole('link', {name: 'unsubscribe'}).should('be.visible').and('have.attr', 'href').and('include', 'forms.mattermost.com/UnsubscribePage.html'); + }); }); it('should match elements, footer', () => { diff --git a/e2e-tests/cypress/tests/integration/channels/team_settings/closed_team_invite_by_email_spec.js b/e2e-tests/cypress/tests/integration/channels/team_settings/closed_team_invite_by_email_spec.js index f79a019a9fb..9512f2bf3c1 100644 --- a/e2e-tests/cypress/tests/integration/channels/team_settings/closed_team_invite_by_email_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/team_settings/closed_team_invite_by_email_spec.js @@ -85,7 +85,7 @@ describe('Team Settings', () => { cy.get('.InviteAs').findByTestId('inviteMembersLink').click(); } - cy.findByRole('combobox', {name: 'Add or Invite People'}).type(email, {force: true}).wait(TIMEOUTS.HALF_SEC).type('{enter}', {force: true}); + cy.findByRole('combobox', {name: 'Invite People'}).type(email, {force: true}).wait(TIMEOUTS.HALF_SEC).type('{enter}', {force: true}); cy.findByTestId('inviteButton').click(); // # Wait for a while to ensure that email notification is sent and logout from sysadmin account @@ -115,7 +115,7 @@ describe('Team Settings', () => { cy.get('#input_password-input').type(password); // # Attempt to create an account by clicking on the 'Create Account' button - cy.findByText('Create Account').click(); + cy.findByText('Create account').click(); // # Close the onboarding tutorial cy.uiCloseOnboardingTaskList(); diff --git a/e2e-tests/cypress/tests/integration/channels/team_settings/helpers.js b/e2e-tests/cypress/tests/integration/channels/team_settings/helpers.js index fe1435c1cbc..a05e9835358 100644 --- a/e2e-tests/cypress/tests/integration/channels/team_settings/helpers.js +++ b/e2e-tests/cypress/tests/integration/channels/team_settings/helpers.js @@ -36,7 +36,7 @@ export const inviteUserByEmail = (email) => { // # Wait half a second to ensure that the modal has been fully loaded cy.wait(TIMEOUTS.HALF_SEC); - cy.findByRole('textbox', {name: 'Add or Invite People'}). + cy.findByRole('textbox', {name: 'Invite People'}). typeWithForce(email). wait(TIMEOUTS.HALF_SEC). typeWithForce('{enter}'); @@ -71,7 +71,7 @@ export const signupAndVerifyTutorial = (username, password, teamDisplayName) => cy.get('#name', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').type(username); cy.get('#password').should('be.visible').type(password); - // # Attempt to create an account by clicking on the 'Create Account' button + // # Attempt to create an account by clicking on the 'Create account' button cy.get('#createAccountButton').click(); // # Close the onboarding tutorial diff --git a/e2e-tests/cypress/tests/integration/channels/team_settings/invite_user_to_closed_team_spec.js b/e2e-tests/cypress/tests/integration/channels/team_settings/invite_user_to_closed_team_spec.js index 6e61eae7c3e..b6ae092b585 100644 --- a/e2e-tests/cypress/tests/integration/channels/team_settings/invite_user_to_closed_team_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/team_settings/invite_user_to_closed_team_spec.js @@ -49,11 +49,14 @@ describe('Team Settings', () => { // # Set 'sample.mattermost.com' as the only allowed email domain and save cy.get('#allowedDomains').click().type(emailDomain).type(' '); cy.findByText('Save').should('be.visible').click(); - - // # Close the modal - cy.get('#teamSettingsModalLabel').find('button').should('be.visible').click(); }); + // # Close the modal + cy.findByLabelText('Close').click(); + + // * Wait for modal to be closed + cy.get('#teamSettingsModal').should('not.exist'); + // # Open team menu and click 'Invite People' cy.uiOpenTeamMenu('Invite people'); @@ -63,7 +66,7 @@ describe('Team Settings', () => { // * Assert that the user has successfully been invited to the team cy.get('.invitation-modal-confirm--sent').should('be.visible').within(() => { cy.get('.username-or-icon').find('span').eq(0).should('have.text', userDetailsString); - cy.get('.InviteResultRow').find('div').eq(1).should('have.text', inviteSuccessMessage); + cy.get('.InviteResultRow').find('.reason').should('have.text', inviteSuccessMessage); }); // # Click on the 'Invite More People button' @@ -75,14 +78,14 @@ describe('Team Settings', () => { // * Assert that the invite failed and the correct error message is shown cy.get('.invitation-modal-confirm--not-sent').should('be.visible').within(() => { cy.get('.username-or-icon').find('span').eq(1).should('have.text', invalidEmail); - cy.get('.InviteResultRow').find('div').eq(1).should('have.text', inviteFailedMessage); + cy.get('.InviteResultRow').find('.reason').should('have.text', inviteFailedMessage); }); }); function inviteNewMemberToTeam(email) { cy.wait(TIMEOUTS.HALF_SEC); - cy.findByRole('combobox', {name: 'Add or Invite People'}). + cy.findByRole('combobox', {name: 'Invite People'}). typeWithForce(email). wait(TIMEOUTS.HALF_SEC). typeWithForce('{enter}'); diff --git a/e2e-tests/cypress/tests/integration/channels/team_settings/join_closed_team_with_not_allowed_email_spec.js b/e2e-tests/cypress/tests/integration/channels/team_settings/join_closed_team_with_not_allowed_email_spec.js index 0e5096cff4f..976ad356bc3 100644 --- a/e2e-tests/cypress/tests/integration/channels/team_settings/join_closed_team_with_not_allowed_email_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/team_settings/join_closed_team_with_not_allowed_email_spec.js @@ -87,8 +87,8 @@ describe('Team Settings', () => { cy.get('#input_name').type(username); cy.get('#input_password-input').type(password); - // # Attempt to create an account by clicking on the 'Create Account' button - cy.findByText('Create Account').click(); + // # Attempt to create an account by clicking on the 'Create account' button + cy.findByText('Create account').click(); // * Assert that the expected error message from creating an account with an email not from the allowed email domain exists and is visible cy.findByText(errorMessage).should('be.visible'); diff --git a/e2e-tests/cypress/tests/plugins/db_request.js b/e2e-tests/cypress/tests/plugins/db_request.js index 2b91086f278..efca9984d78 100644 --- a/e2e-tests/cypress/tests/plugins/db_request.js +++ b/e2e-tests/cypress/tests/plugins/db_request.js @@ -1,13 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -/** - * Functions here are expected to work with MySQL and PostgreSQL (known as dialect). - * When updating this file, make sure to test in both dialect. - * You'll find table and columns names are being converted to lowercase. Reason being is that - * in MySQL, first letter is capitalized. - */ - const mapKeys = require('lodash.mapkeys'); function convertKeysToLowercase(obj) { @@ -33,12 +26,12 @@ const dbGetActiveUserSessions = async ({dbConfig, params: {username, userId, lim try { let user; if (username) { - user = await knexClient(toLowerCase(dbConfig, 'Users')).where('username', username).first(); + user = await knexClient('users').where('username', username).first(); user = convertKeysToLowercase(user); } const now = Date.now(); - const sessions = await knexClient(toLowerCase(dbConfig, 'Sessions')). + const sessions = await knexClient('sessions'). where('userid', user ? user.id : userId). where('expiresat', '>', now). orderBy('lastactivityat', 'desc'). @@ -60,7 +53,7 @@ const dbGetUser = async ({dbConfig, params: {username}}) => { } try { - const user = await knexClient(toLowerCase(dbConfig, 'Users')).where('username', username).first(); + const user = await knexClient('users').where('username', username).first(); return {user: convertKeysToLowercase(user)}; } catch (error) { @@ -75,7 +68,7 @@ const dbGetUserSession = async ({dbConfig, params: {sessionId}}) => { } try { - const session = await knexClient(toLowerCase(dbConfig, 'Sessions')). + const session = await knexClient('sessions'). where('id', '=', sessionId). first(); @@ -92,7 +85,7 @@ const dbUpdateUserSession = async ({dbConfig, params: {sessionId, userId, fields } try { - let user = await knexClient(toLowerCase(dbConfig, 'Users')).where('id', userId).first(); + let user = await knexClient('users').where('id', userId).first(); if (!user) { return {errorMessage: `No user found with id: ${userId}.`}; } @@ -102,12 +95,12 @@ const dbUpdateUserSession = async ({dbConfig, params: {sessionId, userId, fields user = convertKeysToLowercase(user); - await knexClient(toLowerCase(dbConfig, 'Sessions')). + await knexClient('sessions'). where('id', '=', sessionId). where('userid', '=', user.id). update(fieldsToUpdate); - const session = await knexClient(toLowerCase(dbConfig, 'Sessions')). + const session = await knexClient('sessions'). where('id', '=', sessionId). where('userid', '=', user.id). first(); @@ -119,27 +112,11 @@ const dbUpdateUserSession = async ({dbConfig, params: {sessionId, userId, fields } }; -function toLowerCase(config, name) { - if (config.client === 'mysql') { - return name; - } - - return name.toLowerCase(); -} - const dbRefreshPostStats = async ({dbConfig}) => { if (!knexClient) { knexClient = getKnexClient(dbConfig); } - // Only run for PostgreSQL - if (dbConfig.client !== 'postgres') { - return { - skipped: true, - message: 'Refresh post stats is only supported for PostgreSQL', - }; - } - try { await knexClient.raw('REFRESH MATERIALIZED VIEW posts_by_team_day;'); await knexClient.raw('REFRESH MATERIALIZED VIEW bot_posts_by_team_day;'); diff --git a/e2e-tests/cypress/tests/support/ui/compliance_export.js b/e2e-tests/cypress/tests/support/ui/compliance_export.js index 75a9a5f74fe..9dac1d1676e 100644 --- a/e2e-tests/cypress/tests/support/ui/compliance_export.js +++ b/e2e-tests/cypress/tests/support/ui/compliance_export.js @@ -6,7 +6,7 @@ import * as TIMEOUTS from '../../fixtures/timeouts'; Cypress.Commands.add('uiEnableComplianceExport', (exportFormat = 'csv') => { // * Verify that the page is loaded cy.findByText('Enable Compliance Export:').should('be.visible'); - cy.findByText('Compliance Export time:').should('be.visible'); + cy.findByText('Compliance Export Time:').should('be.visible'); cy.findByText('Export Format:').should('be.visible'); // # Enable compliance export diff --git a/e2e-tests/cypress/tests/utils/admin_console.js b/e2e-tests/cypress/tests/utils/admin_console.js index b18c0ffc406..d990cb0c25e 100644 --- a/e2e-tests/cypress/tests/utils/admin_console.js +++ b/e2e-tests/cypress/tests/utils/admin_console.js @@ -241,7 +241,7 @@ export const adminConsoleNavigation = [ }, { type: ['team', 'e20', 'cloud_enterprise'], - header: 'Email Authentication', + header: 'Email', sidebar: 'Email', url: 'admin_console/authentication/email', }, diff --git a/e2e-tests/playwright/CLAUDE.md b/e2e-tests/playwright/CLAUDE.OPTIONAL.md similarity index 100% rename from e2e-tests/playwright/CLAUDE.md rename to e2e-tests/playwright/CLAUDE.OPTIONAL.md diff --git a/e2e-tests/playwright/README.md b/e2e-tests/playwright/README.md index 22d26fe4e2c..a48762348f6 100644 --- a/e2e-tests/playwright/README.md +++ b/e2e-tests/playwright/README.md @@ -150,7 +150,7 @@ test( Change to the `./` project directory, then run the docker container. (See https://playwright.dev/docs/docker for reference.) ```bash -docker run -it --rm -v "$(pwd):/mattermost/" --ipc=host mcr.microsoft.com/playwright:v1.56.0-noble /bin/bash +docker run -it --rm -v "$(pwd):/mattermost/" --ipc=host mcr.microsoft.com/playwright:v1.57.0-noble /bin/bash ``` #### 2. Inside the docker container diff --git a/e2e-tests/playwright/lib/package.json b/e2e-tests/playwright/lib/package.json index f9c337961c9..d4a9d28d7f8 100644 --- a/e2e-tests/playwright/lib/package.json +++ b/e2e-tests/playwright/lib/package.json @@ -1,6 +1,6 @@ { "name": "@mattermost/playwright-lib", - "version": "11.0.0", + "version": "11.3.0", "description": "A comprehensive end-to-end testing library for Mattermost web, desktop and plugin applications using Playwright", "repository": { "type": "git", @@ -42,27 +42,26 @@ "access": "public" }, "dependencies": { - "@axe-core/playwright": "4.10.2", + "@axe-core/playwright": "4.11.0", "@mattermost/client": "file:../../../webapp/platform/client", "@mattermost/types": "file:../../../webapp/platform/types", - "@percy/cli": "1.31.3", - "@percy/playwright": "1.0.9", - "async-wait-until": "2.0.30", + "@percy/cli": "1.31.5", + "@percy/playwright": "1.0.10", + "async-wait-until": "2.0.31", "axe-core": "4.11.0", "deepmerge": "4.3.1", "dotenv": "17.2.3", - "mime-types": "3.0.1", + "mime-types": "3.0.2", "uuid": "13.0.0" }, "devDependencies": { - "@rollup/plugin-typescript": "12.1.4", + "@rollup/plugin-typescript": "12.3.0", "@types/mime-types": "3.0.1", - "@types/node": "24.7.2", - "@types/react": "19.2.2", - "rollup": "4.52.4", + "@types/node": "25.0.3", + "rollup": "4.53.5", "rollup-plugin-copy": "3.5.0" }, "peerDependencies": { - "@playwright/test": "1.56.0" + "@playwright/test": "1.57.0" } } diff --git a/e2e-tests/playwright/lib/src/global_setup.ts b/e2e-tests/playwright/lib/src/global_setup.ts index a3cf66518ba..30215d64625 100644 --- a/e2e-tests/playwright/lib/src/global_setup.ts +++ b/e2e-tests/playwright/lib/src/global_setup.ts @@ -6,7 +6,7 @@ import {PluginManifest} from '@mattermost/types/plugins'; import {PreferenceType} from '@mattermost/types/preferences'; import {UserProfile} from '@mattermost/types/users'; -import {createRandomTeam, getAdminClient, getDefaultAdminUser, makeClient} from './server'; +import {createNewTeam, getAdminClient, getDefaultAdminUser, makeClient} from './server'; import {testConfig} from './test_config'; import {defaultTeam} from './util'; @@ -45,7 +45,7 @@ async function sysadminSetup(client: Client4, user: UserProfile | null) { const myTeams = await client.getMyTeams(); const myDefaultTeam = myTeams && myTeams.length > 0 && myTeams.find((team) => team.name === defaultTeam.name); if (!myDefaultTeam) { - await client.createTeam(await createRandomTeam(defaultTeam.name, defaultTeam.displayName, 'O', false)); + await createNewTeam(client, {name: defaultTeam.name, displayName: defaultTeam.displayName}); } else if (myDefaultTeam && testConfig.resetBeforeTest) { await Promise.all( myTeams.filter((team) => team.name !== defaultTeam.name).map((team) => client.deleteTeam(team.id)), @@ -163,6 +163,8 @@ async function savePreferences(client: Client4, userId: UserProfile['id']) { const preferences: PreferenceType[] = [ {user_id: userId, category: 'tutorial_step', name: userId, value: '999'}, {user_id: userId, category: 'crt_thread_pane_step', name: userId, value: '999'}, + {user_id: userId, category: 'onboarding_task_list', name: 'onboarding_task_list_show', value: 'false'}, + {user_id: userId, category: 'onboarding_task_list', name: 'onboarding_task_list_open', value: 'false'}, ]; await client.savePreferences(userId, preferences); diff --git a/e2e-tests/playwright/lib/src/server/default_config.ts b/e2e-tests/playwright/lib/src/server/default_config.ts index 3724f942315..905828ec51f 100644 --- a/e2e-tests/playwright/lib/src/server/default_config.ts +++ b/e2e-tests/playwright/lib/src/server/default_config.ts @@ -86,7 +86,7 @@ const onPremServerConfig = (): Partial => { }; // Should be based only from the generated default config from ./server via "make config-reset" -// Based on v11.0 server +// Based on v11.3 server const defaultServerConfig: AdminConfig = { ServiceSettings: { SiteURL: '', @@ -110,6 +110,7 @@ const defaultServerConfig: AdminConfig = { MaximumLoginAttempts: 10, GoroutineHealthThreshold: -1, EnableOAuthServiceProvider: true, + EnableDynamicClientRegistration: false, EnableIncomingWebhooks: true, EnableOutgoingWebhooks: true, EnableOutgoingOAuthConnections: false, @@ -187,6 +188,10 @@ const defaultServerConfig: AdminConfig = { PersistentNotificationIntervalMinutes: 5, PersistentNotificationMaxCount: 6, PersistentNotificationMaxRecipients: 5, + EnableBurnOnRead: false, + BurnOnReadDurationSeconds: 600, + BurnOnReadMaximumTimeToLiveSeconds: 604800, + BurnOnReadSchedulerFrequencySeconds: 600, EnableAPIChannelDeletion: false, EnableLocalMode: false, LocalModeSocketLocation: '/var/tmp/mattermost_local.socket', @@ -599,6 +604,7 @@ const defaultServerConfig: AdminConfig = { ClientSideUserIds: [], }, ExperimentalSettings: { + ClientSideCertEnable: false, LinkMetadataTimeoutMilliseconds: 5000, RestrictSystemAdmin: false, EnableSharedChannels: false, @@ -765,12 +771,13 @@ const defaultServerConfig: AdminConfig = { ExperimentalAuditSettingsSystemConsoleUI: true, CustomProfileAttributes: true, AttributeBasedAccessControl: true, - ContentFlagging: false, + ContentFlagging: true, InteractiveDialogAppsForm: true, EnableMattermostEntry: true, - ChannelAdminManageABACRules: false, MobileSSOCodeExchange: true, AutoTranslation: false, + BurnOnRead: false, + EnableAIPluginBridge: false, }, ImportSettings: { Directory: './import', @@ -836,14 +843,14 @@ const defaultServerConfig: AdminConfig = { AutoTranslationSettings: { Enable: false, Provider: '', - LibreTranslate: { - URL: '', - APIKey: '', - }, - TimeoutMs: { + TimeoutsMs: { NewPost: 800, Fetch: 2000, Notification: 300, }, + LibreTranslate: { + URL: '', + APIKey: '', + }, }, }; diff --git a/e2e-tests/playwright/lib/src/server/index.ts b/e2e-tests/playwright/lib/src/server/index.ts index f11d44c6038..f3561c3805e 100644 --- a/e2e-tests/playwright/lib/src/server/index.ts +++ b/e2e-tests/playwright/lib/src/server/index.ts @@ -6,5 +6,5 @@ export {createRandomChannel} from './channel'; export {getOnPremServerConfig, mergeWithOnPremServerConfig} from './default_config'; export {initSetup, getAdminClient} from './init'; export {createRandomPost} from './post'; -export {createRandomTeam} from './team'; +export {createNewTeam, createRandomTeam} from './team'; export {createNewUserProfile, createRandomUser, getDefaultAdminUser, isOutsideRemoteUserHour} from './user'; diff --git a/e2e-tests/playwright/lib/src/server/init.ts b/e2e-tests/playwright/lib/src/server/init.ts index 882b54bfa5a..1a7ecf7d6db 100644 --- a/e2e-tests/playwright/lib/src/server/init.ts +++ b/e2e-tests/playwright/lib/src/server/init.ts @@ -2,21 +2,27 @@ // See LICENSE.txt for license information. import {expect} from '@playwright/test'; -import {PreferenceType} from '@mattermost/types/preferences'; +import {TeamType} from '@mattermost/types/teams'; import {makeClient} from './client'; import {getOnPremServerConfig} from './default_config'; -import {createRandomTeam} from './team'; -import {createRandomUser} from './user'; +import {createNewTeam} from './team'; +import {createNewUserProfile} from './user'; import {getFileFromCommonAsset} from '@/file'; import {testConfig} from '@/test_config'; +type InitSetupOptions = { + userOptions?: Partial[1]>; + teamsOptions?: Partial[1]>; + withDefaultProfileImage?: boolean; +}; + export async function initSetup({ - userPrefix = 'user', - teamPrefix = {name: 'team', displayName: 'Team'}, + userOptions = {prefix: 'user', disableTutorial: true, disableOnboarding: true}, + teamsOptions = {name: 'team', displayName: 'Team', type: 'O' as TeamType, unique: true}, withDefaultProfileImage = true, -} = {}) { +}: Partial = {}) { try { // Login the admin user via API const {adminClient, adminUser} = await getAdminClient(); @@ -33,12 +39,10 @@ export async function initSetup({ const adminConfig = await adminClient.updateConfig(getOnPremServerConfig() as any); // Create new team - const team = await adminClient.createTeam(await createRandomTeam(teamPrefix.name, teamPrefix.displayName)); + const team = await createNewTeam(adminClient, teamsOptions); // Create new user and add to newly created team - const randomUser = await createRandomUser(userPrefix); - const user = await adminClient.createUser(randomUser, '', ''); - user.password = randomUser.password; + const user = await createNewUserProfile(adminClient, userOptions); await adminClient.addToTeam(team.id, user.id); // Log in new user via API @@ -49,13 +53,6 @@ export async function initSetup({ await userClient.uploadProfileImage(user.id, file); } - // Update user preference - const preferences: PreferenceType[] = [ - {user_id: user.id, category: 'tutorial_step', name: user.id, value: '999'}, - {user_id: user.id, category: 'crt_thread_pane_step', name: user.id, value: '999'}, - ]; - await userClient.savePreferences(user.id, preferences); - return { adminClient, adminUser, diff --git a/e2e-tests/playwright/lib/src/server/team.ts b/e2e-tests/playwright/lib/src/server/team.ts index 6ea48c1ba32..8b646a43bb9 100644 --- a/e2e-tests/playwright/lib/src/server/team.ts +++ b/e2e-tests/playwright/lib/src/server/team.ts @@ -1,10 +1,26 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {Client4} from '@mattermost/client'; import {Team, TeamType} from '@mattermost/types/teams'; import {getRandomId} from '@/util'; +export async function createNewTeam( + client: Client4, + options: {name?: string; displayName?: string; type?: TeamType; unique?: boolean} = { + name: 'team', + displayName: 'Team', + type: 'O' as TeamType, + unique: true, + }, +) { + const randomTeam = await createRandomTeam(options.name, options.displayName, options.type, options.unique); + const newTeam = await client.createTeam(randomTeam); + + return newTeam; +} + export async function createRandomTeam( name = 'team', displayName = 'Team', diff --git a/e2e-tests/playwright/lib/src/server/user.ts b/e2e-tests/playwright/lib/src/server/user.ts index d5c6569c242..d922c68d20e 100644 --- a/e2e-tests/playwright/lib/src/server/user.ts +++ b/e2e-tests/playwright/lib/src/server/user.ts @@ -9,12 +9,34 @@ import {getRandomId} from '@/util'; import {testConfig} from '@/test_config'; import {REMOTE_USERS_HOUR_LIMIT_END_OF_THE_DAY, REMOTE_USERS_HOUR_LIMIT_BEGINNING_OF_THE_DAY} from '@/constant'; -export async function createNewUserProfile(client: Client4, prefix = 'user') { - const randomUser = await createRandomUser(prefix); +export async function createNewUserProfile( + client: Client4, + options: {prefix?: string; disableTutorial?: boolean; disableOnboarding?: boolean} = { + prefix: 'user', + disableTutorial: true, + disableOnboarding: true, + }, +) { + const randomUser = await createRandomUser(options.prefix); const newUser = await client.createUser(randomUser, '', ''); + // Set password to the created user profile so it can be used to login later newUser.password = randomUser.password; + if (options.disableTutorial) { + await client.savePreferences(newUser.id, [ + {user_id: newUser.id, category: 'tutorial_step', name: newUser.id, value: '999'}, + {user_id: newUser.id, category: 'crt_thread_pane_step', name: newUser.id, value: '999'}, + ]); + } + + if (options.disableOnboarding) { + await client.savePreferences(newUser.id, [ + {user_id: newUser.id, category: 'onboarding_task_list', name: 'onboarding_task_list_show', value: 'false'}, + {user_id: newUser.id, category: 'onboarding_task_list', name: 'onboarding_task_list_open', value: 'false'}, + ]); + } + return newUser; } diff --git a/e2e-tests/playwright/lib/src/test_fixture.ts b/e2e-tests/playwright/lib/src/test_fixture.ts index f6d6273addd..89b8e869767 100644 --- a/e2e-tests/playwright/lib/src/test_fixture.ts +++ b/e2e-tests/playwright/lib/src/test_fixture.ts @@ -19,6 +19,7 @@ import { import {getBlobFromAsset, getFileFromAsset} from './file'; import { createNewUserProfile, + createNewTeam, createRandomChannel, createRandomPost, createRandomTeam, @@ -102,6 +103,7 @@ export class PlaywrightExtended { // ./server readonly createNewUserProfile; + readonly createNewTeam; readonly isOutsideRemoteUserHour; readonly makeClient; @@ -167,6 +169,7 @@ export class PlaywrightExtended { // ./server this.createNewUserProfile = createNewUserProfile; + this.createNewTeam = createNewTeam; this.makeClient = makeClient; // ./visual diff --git a/e2e-tests/playwright/lib/src/ui/components/channels/post.ts b/e2e-tests/playwright/lib/src/ui/components/channels/post.ts index 21fb5eb79dd..7c15b7befb3 100644 --- a/e2e-tests/playwright/lib/src/ui/components/channels/post.ts +++ b/e2e-tests/playwright/lib/src/ui/components/channels/post.ts @@ -85,4 +85,12 @@ export default class ChannelsPost { async toContainText(text: string) { await expect(this.container).toContainText(text); } + + /** + * `toNotContainText` verifies if the post does not contain the specified text. + * @param text Text to be verified not in the post + */ + async toNotContainText(text: string) { + await expect(this.container).not.toContainText(text); + } } diff --git a/e2e-tests/playwright/lib/src/ui/pages/channels.ts b/e2e-tests/playwright/lib/src/ui/pages/channels.ts index 0f5cece35cd..7cb697470a1 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/channels.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/channels.ts @@ -86,6 +86,14 @@ export default class ChannelsPage { await this.centerView.toBeVisible(); } + /** + * `toNotContainText` verifies if the page does not contain the specified text. + * @param text Text to be verified not in the page + */ + async toNotContainText(text: string) { + await expect(this.page.locator('body')).not.toContainText(text); + } + async getLastPost() { return this.centerView.getLastPost(); } diff --git a/e2e-tests/playwright/lib/src/ui/pages/signup.ts b/e2e-tests/playwright/lib/src/ui/pages/signup.ts index cb43497b3b5..240bbcbfe20 100644 --- a/e2e-tests/playwright/lib/src/ui/pages/signup.ts +++ b/e2e-tests/playwright/lib/src/ui/pages/signup.ts @@ -41,7 +41,7 @@ export default class SignupPage { this.usernameInput = page.locator('#input_name'); this.passwordInput = page.locator('#input_password-input'); this.passwordToggleButton = page.locator('#password_toggle'); - this.createAccountButton = page.locator('button:has-text("Create Account")'); + this.createAccountButton = page.locator('button:has-text("Create account")'); this.emailError = page.locator('text=Please enter a valid email address'); this.usernameError = page.locator( 'text=Usernames have to begin with a lowercase letter and be 3-22 characters long. You can use lowercase letters, numbers, periods, dashes, and underscores.', diff --git a/e2e-tests/playwright/package-lock.json b/e2e-tests/playwright/package-lock.json index e41f74663d2..1de0c83ebdf 100644 --- a/e2e-tests/playwright/package-lock.json +++ b/e2e-tests/playwright/package-lock.json @@ -15,33 +15,36 @@ "@mattermost/types": "file:../../webapp/platform/types" }, "devDependencies": { - "@playwright/test": "1.56.0", + "@playwright/test": "1.57.0", "@types/luxon": "3.7.1", - "@typescript-eslint/eslint-plugin": "8.46.1", + "@typescript-eslint/eslint-plugin": "8.50.0", "cross-env": "10.1.0", - "dayjs": "1.11.18", - "eslint": "9.37.0", + "dayjs": "1.11.19", + "eslint": "9.39.2", "eslint-import-resolver-typescript": "4.4.4", "eslint-plugin-header": "3.1.1", "eslint-plugin-import": "2.32.0", - "glob": "11.0.3", + "glob": "13.0.0", "luxon": "3.7.2", - "prettier": "3.6.2", + "prettier": "3.7.4", "typescript": "5.9.3", - "zod": "4.1.12" + "zod": "4.2.1" } }, "../../webapp/platform/client": { "name": "@mattermost/client", - "version": "10.12.0", + "version": "11.3.0", "license": "MIT", "devDependencies": { - "@types/jest": "28.1.8", - "jest": "27.1.0", + "@types/jest": "30.0.0", + "@types/node-fetch": "2.6.13", + "jest": "30.1.3", + "nock": "13.2.8", + "node-fetch": "2.7.0", "typescript": "^5.0.0" }, "peerDependencies": { - "@mattermost/types": "10.12.0", + "@mattermost/types": "11.3.0", "typescript": "^4.3.0 || ^5.0.0" }, "peerDependenciesMeta": { @@ -52,7 +55,7 @@ }, "../../webapp/platform/types": { "name": "@mattermost/types", - "version": "10.12.0", + "version": "11.3.0", "license": "MIT", "devDependencies": { "typescript": "^5.0.0" @@ -68,61 +71,51 @@ }, "lib": { "name": "@mattermost/playwright-lib", - "version": "11.0.0", + "version": "11.3.0", "license": "MIT", "dependencies": { - "@axe-core/playwright": "4.10.2", + "@axe-core/playwright": "4.11.0", "@mattermost/client": "file:../../../webapp/platform/client", "@mattermost/types": "file:../../../webapp/platform/types", - "@percy/cli": "1.31.3", - "@percy/playwright": "1.0.9", - "async-wait-until": "2.0.30", + "@percy/cli": "1.31.5", + "@percy/playwright": "1.0.10", + "async-wait-until": "2.0.31", "axe-core": "4.11.0", "deepmerge": "4.3.1", "dotenv": "17.2.3", - "mime-types": "3.0.1", + "mime-types": "3.0.2", "uuid": "13.0.0" }, "devDependencies": { - "@rollup/plugin-typescript": "12.1.4", + "@rollup/plugin-typescript": "12.3.0", "@types/mime-types": "3.0.1", - "@types/node": "24.7.2", - "@types/react": "19.2.2", - "rollup": "4.52.4", + "@types/node": "25.0.3", + "rollup": "4.53.5", "rollup-plugin-copy": "3.5.0" }, "peerDependencies": { - "@playwright/test": "1.56.0" + "@playwright/test": "1.57.0" } }, "node_modules/@axe-core/playwright": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.10.2.tgz", - "integrity": "sha512-6/b5BJjG6hDaRNtgzLIfKr5DfwyiLHO4+ByTLB0cJgWSM8Ll7KqtdblIS6bEkwSF642/Ex91vNqIl3GLXGlceg==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.0.tgz", + "integrity": "sha512-70vBT/Ylqpm65RQz2iCG2o0JJCEG/WCNyefTr2xcOcr1CoSee60gNQYUMZZ7YukoKkFLv26I/jjlsvwwp532oQ==", "license": "MPL-2.0", "dependencies": { - "axe-core": "~4.10.3" + "axe-core": "~4.11.0" }, "peerDependencies": { "playwright-core": ">= 1.0.0" } }, - "node_modules/@axe-core/playwright/node_modules/axe-core": { - "version": "4.10.3", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.3.tgz", - "integrity": "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==", - "license": "MPL-2.0", - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -131,18 +124,18 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@emnapi/core": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz", - "integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", "dev": true, "license": "MIT", "optional": true, @@ -152,9 +145,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz", - "integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "dev": true, "license": "MIT", "optional": true, @@ -181,9 +174,9 @@ "license": "MIT" }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -200,9 +193,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -210,13 +203,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -249,22 +242,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.0.tgz", - "integrity": "sha512-WUFvV4WoIwW8Bv0KeKCIIEgdSiFOsulyN0xrMu+7z43q/hkOLXjvb5u7UC9jDxvRzcrbEmuZBX5yJZz1741jog==", + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0" + "@eslint/core": "^0.17.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", - "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -275,9 +268,9 @@ } }, "node_modules/@eslint/eslintrc": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", - "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", "dev": true, "license": "MIT", "dependencies": { @@ -287,7 +280,7 @@ "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", + "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" }, @@ -333,9 +326,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.37.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.37.0.tgz", - "integrity": "sha512-jaS+NJ+hximswBG6pjNX0uEJZkrT0zwpVi3BA3vX22aFGjJjmgSTSmPpZCRKmoBL5VY/M6p0xsSJx7rk7sy5gg==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", "dev": true, "license": "MIT", "engines": { @@ -346,9 +339,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -356,13 +349,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", - "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0", + "@eslint/core": "^0.17.0", "levn": "^0.4.1" }, "engines": { @@ -444,24 +437,6 @@ "node": "20 || >=22" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/@mattermost/client": { "resolved": "../../webapp/platform/client", "link": true @@ -523,20 +498,20 @@ } }, "node_modules/@percy/cli": { - "version": "1.31.3", - "resolved": "https://registry.npmjs.org/@percy/cli/-/cli-1.31.3.tgz", - "integrity": "sha512-BhxNjwTWuN1xxin1bc1qGCqe2suusFUDKYot7e90UGRa6wUSZsyjmzC9kGfb3IhEmpM0EswQSePp7RR2d6saww==", + "version": "1.31.5", + "resolved": "https://registry.npmjs.org/@percy/cli/-/cli-1.31.5.tgz", + "integrity": "sha512-4Mhb7hWfwxrD6pAbkSvWzR226rIX1xPsSP1bJE/bJUiDy7SZcfQqwG65fP78BhnDT6ehPvlmhot8B33v8AeWOw==", "license": "MIT", "dependencies": { - "@percy/cli-app": "1.31.3", - "@percy/cli-build": "1.31.3", - "@percy/cli-command": "1.31.3", - "@percy/cli-config": "1.31.3", - "@percy/cli-exec": "1.31.3", - "@percy/cli-snapshot": "1.31.3", - "@percy/cli-upload": "1.31.3", - "@percy/client": "1.31.3", - "@percy/logger": "1.31.3" + "@percy/cli-app": "1.31.5", + "@percy/cli-build": "1.31.5", + "@percy/cli-command": "1.31.5", + "@percy/cli-config": "1.31.5", + "@percy/cli-exec": "1.31.5", + "@percy/cli-snapshot": "1.31.5", + "@percy/cli-upload": "1.31.5", + "@percy/client": "1.31.5", + "@percy/logger": "1.31.5" }, "bin": { "percy": "bin/run.cjs" @@ -546,39 +521,39 @@ } }, "node_modules/@percy/cli-app": { - "version": "1.31.3", - "resolved": "https://registry.npmjs.org/@percy/cli-app/-/cli-app-1.31.3.tgz", - "integrity": "sha512-g9NnYQgVVugGQZhUnpd4bJY9zfZnSOM4jJVPrdA2cOO+JxKJr7079JCkcNdrOqmtSbZ01XugSKz5UgQN+SUgMg==", + "version": "1.31.5", + "resolved": "https://registry.npmjs.org/@percy/cli-app/-/cli-app-1.31.5.tgz", + "integrity": "sha512-Rzc0QzFov5/Ak29bQ1eIWVtJaj6Aqd3QJGE6sqR+WOUHI0sUQjSOV+8QHLOfx1/8Db2Ne+AbuKVGIWesRW9gVQ==", "license": "MIT", "dependencies": { - "@percy/cli-command": "1.31.3", - "@percy/cli-exec": "1.31.3" + "@percy/cli-command": "1.31.5", + "@percy/cli-exec": "1.31.5" }, "engines": { "node": ">=14" } }, "node_modules/@percy/cli-build": { - "version": "1.31.3", - "resolved": "https://registry.npmjs.org/@percy/cli-build/-/cli-build-1.31.3.tgz", - "integrity": "sha512-VNybZILAj9GoEf2lB/DueO+KibEhgV2Gbh1kCUsUwmINOrZy2vD6Izy+asCPgdLWY5xe09K2QuLPOfgq5JfZWw==", + "version": "1.31.5", + "resolved": "https://registry.npmjs.org/@percy/cli-build/-/cli-build-1.31.5.tgz", + "integrity": "sha512-bA6B1EQbJIr5AiaS+8jP7L+sbiLsvW57lmG++sybfVqdHi7qAlofK71c+Dpr8af6xHPKm8h8phw05CxYSkTiYw==", "license": "MIT", "dependencies": { - "@percy/cli-command": "1.31.3" + "@percy/cli-command": "1.31.5" }, "engines": { "node": ">=14" } }, "node_modules/@percy/cli-command": { - "version": "1.31.3", - "resolved": "https://registry.npmjs.org/@percy/cli-command/-/cli-command-1.31.3.tgz", - "integrity": "sha512-NLF4HoLkz3vlgIIfgweQgwFowyOG5PYD67DCzzwK34picoeOa6u1zlx1mhaZA9L1kDwJcfTSNSbht7CpBiL6JQ==", + "version": "1.31.5", + "resolved": "https://registry.npmjs.org/@percy/cli-command/-/cli-command-1.31.5.tgz", + "integrity": "sha512-GUfkOP5v6F0DPa+Msnpk4hf4f1Yh+fqbd9vApCsvDMkDF6Zg+bIALD3CPZqdjXded2q+VuIjIC+HJUlIR3Xodg==", "license": "MIT", "dependencies": { - "@percy/config": "1.31.3", - "@percy/core": "1.31.3", - "@percy/logger": "1.31.3" + "@percy/config": "1.31.5", + "@percy/core": "1.31.5", + "@percy/logger": "1.31.5" }, "bin": { "percy-cli-readme": "bin/readme.js" @@ -588,25 +563,25 @@ } }, "node_modules/@percy/cli-config": { - "version": "1.31.3", - "resolved": "https://registry.npmjs.org/@percy/cli-config/-/cli-config-1.31.3.tgz", - "integrity": "sha512-hELWI27R9RyRGC59f30+Ob9Zy07OuGPqrm9SccGsybpj31qZkV0WK8H3vTWkLzPBPoHMnXbshPFYr5VkEsUBTA==", + "version": "1.31.5", + "resolved": "https://registry.npmjs.org/@percy/cli-config/-/cli-config-1.31.5.tgz", + "integrity": "sha512-BaFKg72OtsZX9riJqV38tmtJcku7E+xh4PdQ9iyxKA2gCjxC6fUHbuIihHe5C3aoUiIsD4GzFwKq55S4zrxo9Q==", "license": "MIT", "dependencies": { - "@percy/cli-command": "1.31.3" + "@percy/cli-command": "1.31.5" }, "engines": { "node": ">=14" } }, "node_modules/@percy/cli-exec": { - "version": "1.31.3", - "resolved": "https://registry.npmjs.org/@percy/cli-exec/-/cli-exec-1.31.3.tgz", - "integrity": "sha512-TZiCFoDyNmsw8ZB3dmz2ciVVoApHYkaK4plCBBRY1BI7d6jRBJifct0rNvklnTjHYsCsTGVGthnenAih33kFaA==", + "version": "1.31.5", + "resolved": "https://registry.npmjs.org/@percy/cli-exec/-/cli-exec-1.31.5.tgz", + "integrity": "sha512-ysSHhDnEjh4xOMw8LmbGchqWyc/exq5bDgYhqdzPF8bS8Ke+NYKd9FHENBAzqOkE8mNknf/PQ88hJLIgNjUH5g==", "license": "MIT", "dependencies": { - "@percy/cli-command": "1.31.3", - "@percy/logger": "1.31.3", + "@percy/cli-command": "1.31.5", + "@percy/logger": "1.31.5", "cross-spawn": "^7.0.3", "which": "^2.0.2" }, @@ -615,12 +590,12 @@ } }, "node_modules/@percy/cli-snapshot": { - "version": "1.31.3", - "resolved": "https://registry.npmjs.org/@percy/cli-snapshot/-/cli-snapshot-1.31.3.tgz", - "integrity": "sha512-amurMrPpj3AmoHopdr3utA/iv0Oj6d+BPqbRLvh7OhutPMPOc0zrDKVDKiocfRKTkGVxkBgnt8NTl+rvx450Vg==", + "version": "1.31.5", + "resolved": "https://registry.npmjs.org/@percy/cli-snapshot/-/cli-snapshot-1.31.5.tgz", + "integrity": "sha512-vejf9eJd/r8dSbog0r7ei2lGRoYQd/MvjriG+tSapEXLw5X6hr7XfZY5B/XhqdQDDx39HzHY4z6RkFhSTR20rg==", "license": "MIT", "dependencies": { - "@percy/cli-command": "1.31.3", + "@percy/cli-command": "1.31.5", "yaml": "^2.0.0" }, "engines": { @@ -628,12 +603,12 @@ } }, "node_modules/@percy/cli-upload": { - "version": "1.31.3", - "resolved": "https://registry.npmjs.org/@percy/cli-upload/-/cli-upload-1.31.3.tgz", - "integrity": "sha512-rP+iDQBFguuG2wWN+OwbclM+3Vj01dkxNUcBBEeQ16pZQ0zSbOq5Qt/2Mw93SMoNpa0il4vk4zmcXPgApA+Qgg==", + "version": "1.31.5", + "resolved": "https://registry.npmjs.org/@percy/cli-upload/-/cli-upload-1.31.5.tgz", + "integrity": "sha512-6Jll0dMKMRcwC6dHXLf+9XGfl29vk4r+kX3C522OJhglFdahWLsIYYK/chLZ4td83eqFeMt1ZeBVJBlMPZlHgg==", "license": "MIT", "dependencies": { - "@percy/cli-command": "1.31.3", + "@percy/cli-command": "1.31.5", "fast-glob": "^3.2.11", "image-size": "^1.0.0" }, @@ -642,14 +617,14 @@ } }, "node_modules/@percy/client": { - "version": "1.31.3", - "resolved": "https://registry.npmjs.org/@percy/client/-/client-1.31.3.tgz", - "integrity": "sha512-XzXHNco39qqzclL8y7t/k/VTR+9nhXkyfCqvbxFUS9DDG+Jd2Tv6dpRc5qv7phhp2VoKBdVVu46gMBG+qSl7yg==", + "version": "1.31.5", + "resolved": "https://registry.npmjs.org/@percy/client/-/client-1.31.5.tgz", + "integrity": "sha512-9N4Am+heEsHn1Q2yDcnayPzVl898ZYa4a4OUj+PryExNYcfmXIcNh9ZNoMesY6vr56XqbT0IlhNMpyYwSu0SXg==", "license": "MIT", "dependencies": { - "@percy/config": "1.31.3", - "@percy/env": "1.31.3", - "@percy/logger": "1.31.3", + "@percy/config": "1.31.5", + "@percy/env": "1.31.5", + "@percy/logger": "1.31.5", "pac-proxy-agent": "^7.0.2", "pako": "^2.1.0" }, @@ -658,12 +633,12 @@ } }, "node_modules/@percy/config": { - "version": "1.31.3", - "resolved": "https://registry.npmjs.org/@percy/config/-/config-1.31.3.tgz", - "integrity": "sha512-yTl+MUGNxMv+xQbMXR3g/tISRohE39b+s7216Z3ILtqJejIzxB0jbBfkvGqLAmlBbC63hTjg6s2JECoH7x131w==", + "version": "1.31.5", + "resolved": "https://registry.npmjs.org/@percy/config/-/config-1.31.5.tgz", + "integrity": "sha512-/Y/6tuidR5NHYDcGIqfJjexChKGrbax8mTDmh6ioq5tgppkClZpFG4X3V/VSmM+ZDF/gViZOKq1j/aQI8+MRsg==", "license": "MIT", "dependencies": { - "@percy/logger": "1.31.3", + "@percy/logger": "1.31.5", "ajv": "^8.6.2", "cosmiconfig": "^8.0.0", "yaml": "^2.0.0" @@ -695,18 +670,18 @@ "license": "MIT" }, "node_modules/@percy/core": { - "version": "1.31.3", - "resolved": "https://registry.npmjs.org/@percy/core/-/core-1.31.3.tgz", - "integrity": "sha512-RuYNMDpnybYpE8bcB9ymmBH67gZkL04gtqt5MljYwOvhdtBhG6B/Tn0j7j9wSwA+ob70jzJ+/YHDQkMVOQA+TA==", + "version": "1.31.5", + "resolved": "https://registry.npmjs.org/@percy/core/-/core-1.31.5.tgz", + "integrity": "sha512-uscHqNO5Z9TOeP6sKwL0srk2cqYfXknto9qLNuzQmUapdGmqAMdIdDvDNg3VrRB/Zurr+8ZY96KM3E3DC3SF7g==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@percy/client": "1.31.3", - "@percy/config": "1.31.3", - "@percy/dom": "1.31.3", - "@percy/logger": "1.31.3", - "@percy/monitoring": "1.31.3", - "@percy/webdriver-utils": "1.31.3", + "@percy/client": "1.31.5", + "@percy/config": "1.31.5", + "@percy/dom": "1.31.5", + "@percy/logger": "1.31.5", + "@percy/monitoring": "1.31.5", + "@percy/webdriver-utils": "1.31.5", "content-disposition": "^0.5.4", "cross-spawn": "^7.0.3", "extract-zip": "^2.0.1", @@ -745,41 +720,41 @@ } }, "node_modules/@percy/dom": { - "version": "1.31.3", - "resolved": "https://registry.npmjs.org/@percy/dom/-/dom-1.31.3.tgz", - "integrity": "sha512-Sj52zexDmEvDlASrPyf0HFQ8qQOw/X8GjAgdvfCMOiwqxRfEwYGxOYO0L7x69+BVNqaZj4rlvaNdJJ9RekQLnA==", + "version": "1.31.5", + "resolved": "https://registry.npmjs.org/@percy/dom/-/dom-1.31.5.tgz", + "integrity": "sha512-eFQDgf9mgd0skJmn4NeOgJmxCA4Xayq/ZE9d4zRSqJP/wOa5B0zHwsLdyp3ZFhvEfbe6/5pisnf6rzpFLr+kvA==", "license": "MIT" }, "node_modules/@percy/env": { - "version": "1.31.3", - "resolved": "https://registry.npmjs.org/@percy/env/-/env-1.31.3.tgz", - "integrity": "sha512-luUfJq4gN1PgbvQQGx+cytqxmp/pOThCze/cts2PZx1vxGyRjOzBmMQYlrx99rnKeJ3vw6DVdnAyk1y55nVz6g==", + "version": "1.31.5", + "resolved": "https://registry.npmjs.org/@percy/env/-/env-1.31.5.tgz", + "integrity": "sha512-ZZndzJLJwEq79Gq+eiSeax4HXCn5dRk3WsXVAc9y9sy8+58cGMyW7wYwg4AeATEgzpfbDG/8Bwzzn9cZPkSk4w==", "license": "MIT", "dependencies": { - "@percy/logger": "1.31.3" + "@percy/logger": "1.31.5" }, "engines": { "node": ">=14" } }, "node_modules/@percy/logger": { - "version": "1.31.3", - "resolved": "https://registry.npmjs.org/@percy/logger/-/logger-1.31.3.tgz", - "integrity": "sha512-msfmqpthOblDfsnLzRKTeJeV/qHYMZ1dPDP2mZJmN7BmgADw81imPylrbzCWmvv4ljoQ7vYu4QuAM9qHV3dzFw==", + "version": "1.31.5", + "resolved": "https://registry.npmjs.org/@percy/logger/-/logger-1.31.5.tgz", + "integrity": "sha512-YbVl+8xsxjBH6+p7cPSIN2voP1dZrwOYYskwQeNYAOgfwky8D/mo5xJ2Lt2HgCJCxqbLYmKsb2MyTqztIfAAZQ==", "license": "MIT", "engines": { "node": ">=14" } }, "node_modules/@percy/monitoring": { - "version": "1.31.3", - "resolved": "https://registry.npmjs.org/@percy/monitoring/-/monitoring-1.31.3.tgz", - "integrity": "sha512-+QERs2QMkG27nh2a9feQ1+WEUxS7m2qqt0FzO0TReUbQLH1hNa4qREWhLqeYAzbYI+FKZjtcE6ghVSu8+PZDYA==", + "version": "1.31.5", + "resolved": "https://registry.npmjs.org/@percy/monitoring/-/monitoring-1.31.5.tgz", + "integrity": "sha512-jMOaHcFbd/8/0krzJE42915VXujJweu4X96sfAe6i8n+LWPjPo37diTMXW6nimqmkmgNDb5HRHWYT2gCVyT5oA==", "license": "MIT", "dependencies": { - "@percy/config": "1.31.3", - "@percy/logger": "1.31.3", - "@percy/sdk-utils": "1.31.3", + "@percy/config": "1.31.5", + "@percy/logger": "1.31.5", + "@percy/sdk-utils": "1.31.5", "systeminformation": "^5.25.11" }, "engines": { @@ -787,9 +762,9 @@ } }, "node_modules/@percy/playwright": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@percy/playwright/-/playwright-1.0.9.tgz", - "integrity": "sha512-t74a0hZcAR+ssNpbcL6vnYU5mwEGcdRByLYFb12yFQUq4n250YUAX76jI4OHzH440Tikp84hml4JnbXrvgEmFQ==", + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@percy/playwright/-/playwright-1.0.10.tgz", + "integrity": "sha512-lq2Mbqz/SfguQn4PdbNwApmzZpA/3gWO7STLlyLNYd0r4btGd7Nfxyxkf/t78rgh2ErwGcLUuPbxGPpZ3XXLVw==", "license": "MIT", "engines": { "node": ">=14" @@ -799,9 +774,9 @@ } }, "node_modules/@percy/sdk-utils": { - "version": "1.31.3", - "resolved": "https://registry.npmjs.org/@percy/sdk-utils/-/sdk-utils-1.31.3.tgz", - "integrity": "sha512-iEnhc+x5lVmH6va5FuljAWfSFcjjqcPJ+GgoFvuvvYv39nMgXgsw4qGT+sr2ET+FpBB4f0MNs/Bn7QEkk1TlvA==", + "version": "1.31.5", + "resolved": "https://registry.npmjs.org/@percy/sdk-utils/-/sdk-utils-1.31.5.tgz", + "integrity": "sha512-6hpu8k93LZBhlce+Bvve/tK95ymORpVOO36IDHlSTxm5V9NnMKuad3QyAz4Z70M1oVTdtdgYH+AiFI/IcQoQNw==", "license": "MIT", "dependencies": { "pac-proxy-agent": "^7.0.2" @@ -811,25 +786,26 @@ } }, "node_modules/@percy/webdriver-utils": { - "version": "1.31.3", - "resolved": "https://registry.npmjs.org/@percy/webdriver-utils/-/webdriver-utils-1.31.3.tgz", - "integrity": "sha512-+MzTmpcmuLCX9jYmtJxNV9yE8748DSk0NZfu56Jp4HuOf/LBRnh/ShpHHo4tuEg3qaM3yqNP7KvR99/HpECQRg==", + "version": "1.31.5", + "resolved": "https://registry.npmjs.org/@percy/webdriver-utils/-/webdriver-utils-1.31.5.tgz", + "integrity": "sha512-NU4uMzt2xjEbHFvgdAc9cSQ4fM7y0qGlMvG5cGjkIXz5dOTspVc7wHxyy0KcH20AVqolKqBtL6G0U+5XEerXKg==", "license": "MIT", "dependencies": { - "@percy/config": "1.31.3", - "@percy/sdk-utils": "1.31.3" + "@percy/config": "1.31.5", + "@percy/sdk-utils": "1.31.5" }, "engines": { "node": ">=14" } }, "node_modules/@playwright/test": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.0.tgz", - "integrity": "sha512-Tzh95Twig7hUwwNe381/K3PggZBZblKUe2wv25oIpzWLr6Z0m4KgV1ZVIjnR6GM9ANEqjZD7XsZEa6JL/7YEgg==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", + "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", "license": "Apache-2.0", + "peer": true, "dependencies": { - "playwright": "1.56.0" + "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" @@ -839,9 +815,9 @@ } }, "node_modules/@rollup/plugin-typescript": { - "version": "12.1.4", - "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.1.4.tgz", - "integrity": "sha512-s5Hx+EtN60LMlDBvl5f04bEiFZmAepk27Q+mr85L/00zPDn1jtzlTV6FWn81MaIwqfWzKxmOJrBWHU6vtQyedQ==", + "version": "12.3.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.3.0.tgz", + "integrity": "sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big==", "dev": true, "license": "MIT", "dependencies": { @@ -888,23 +864,10 @@ } } }, - "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.4.tgz", - "integrity": "sha512-BTm2qKNnWIQ5auf4deoetINJm2JzvihvGb9R6K/ETwKLql/Bb3Eg2H1FBp1gUb4YGbydMA3jcmQTR73q7J+GAA==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz", + "integrity": "sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ==", "cpu": [ "arm" ], @@ -916,9 +879,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.4.tgz", - "integrity": "sha512-P9LDQiC5vpgGFgz7GSM6dKPCiqR3XYN1WwJKA4/BUVDjHpYsf3iBEmVz62uyq20NGYbiGPR5cNHI7T1HqxNs2w==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz", + "integrity": "sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw==", "cpu": [ "arm64" ], @@ -930,9 +893,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.4.tgz", - "integrity": "sha512-QRWSW+bVccAvZF6cbNZBJwAehmvG9NwfWHwMy4GbWi/BQIA/laTIktebT2ipVjNncqE6GLPxOok5hsECgAxGZg==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz", + "integrity": "sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ==", "cpu": [ "arm64" ], @@ -944,9 +907,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.4.tgz", - "integrity": "sha512-hZgP05pResAkRJxL1b+7yxCnXPGsXU0fG9Yfd6dUaoGk+FhdPKCJ5L1Sumyxn8kvw8Qi5PvQ8ulenUbRjzeCTw==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz", + "integrity": "sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA==", "cpu": [ "x64" ], @@ -958,9 +921,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.4.tgz", - "integrity": "sha512-xmc30VshuBNUd58Xk4TKAEcRZHaXlV+tCxIXELiE9sQuK3kG8ZFgSPi57UBJt8/ogfhAF5Oz4ZSUBN77weM+mQ==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz", + "integrity": "sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw==", "cpu": [ "arm64" ], @@ -972,9 +935,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.4.tgz", - "integrity": "sha512-WdSLpZFjOEqNZGmHflxyifolwAiZmDQzuOzIq9L27ButpCVpD7KzTRtEG1I0wMPFyiyUdOO+4t8GvrnBLQSwpw==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz", + "integrity": "sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ==", "cpu": [ "x64" ], @@ -986,9 +949,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.4.tgz", - "integrity": "sha512-xRiOu9Of1FZ4SxVbB0iEDXc4ddIcjCv2aj03dmW8UrZIW7aIQ9jVJdLBIhxBI+MaTnGAKyvMwPwQnoOEvP7FgQ==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz", + "integrity": "sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA==", "cpu": [ "arm" ], @@ -1000,9 +963,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.4.tgz", - "integrity": "sha512-FbhM2p9TJAmEIEhIgzR4soUcsW49e9veAQCziwbR+XWB2zqJ12b4i/+hel9yLiD8pLncDH4fKIPIbt5238341Q==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz", + "integrity": "sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ==", "cpu": [ "arm" ], @@ -1014,9 +977,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.4.tgz", - "integrity": "sha512-4n4gVwhPHR9q/g8lKCyz0yuaD0MvDf7dV4f9tHt0C73Mp8h38UCtSCSE6R9iBlTbXlmA8CjpsZoujhszefqueg==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz", + "integrity": "sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg==", "cpu": [ "arm64" ], @@ -1028,9 +991,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.4.tgz", - "integrity": "sha512-u0n17nGA0nvi/11gcZKsjkLj1QIpAuPFQbR48Subo7SmZJnGxDpspyw2kbpuoQnyK+9pwf3pAoEXerJs/8Mi9g==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.5.tgz", + "integrity": "sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g==", "cpu": [ "arm64" ], @@ -1042,9 +1005,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.4.tgz", - "integrity": "sha512-0G2c2lpYtbTuXo8KEJkDkClE/+/2AFPdPAbmaHoE870foRFs4pBrDehilMcrSScrN/fB/1HTaWO4bqw+ewBzMQ==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz", + "integrity": "sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA==", "cpu": [ "loong64" ], @@ -1056,9 +1019,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.4.tgz", - "integrity": "sha512-teSACug1GyZHmPDv14VNbvZFX779UqWTsd7KtTM9JIZRDI5NUwYSIS30kzI8m06gOPB//jtpqlhmraQ68b5X2g==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz", + "integrity": "sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q==", "cpu": [ "ppc64" ], @@ -1070,9 +1033,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.4.tgz", - "integrity": "sha512-/MOEW3aHjjs1p4Pw1Xk4+3egRevx8Ji9N6HUIA1Ifh8Q+cg9dremvFCUbOX2Zebz80BwJIgCBUemjqhU5XI5Eg==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz", + "integrity": "sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ==", "cpu": [ "riscv64" ], @@ -1084,9 +1047,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.4.tgz", - "integrity": "sha512-1HHmsRyh845QDpEWzOFtMCph5Ts+9+yllCrREuBR/vg2RogAQGGBRC8lDPrPOMnrdOJ+mt1WLMOC2Kao/UwcvA==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz", + "integrity": "sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w==", "cpu": [ "riscv64" ], @@ -1098,9 +1061,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.4.tgz", - "integrity": "sha512-seoeZp4L/6D1MUyjWkOMRU6/iLmCU2EjbMTyAG4oIOs1/I82Y5lTeaxW0KBfkUdHAWN7j25bpkt0rjnOgAcQcA==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz", + "integrity": "sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw==", "cpu": [ "s390x" ], @@ -1112,9 +1075,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.4.tgz", - "integrity": "sha512-Wi6AXf0k0L7E2gteNsNHUs7UMwCIhsCTs6+tqQ5GPwVRWMaflqGec4Sd8n6+FNFDw9vGcReqk2KzBDhCa1DLYg==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz", + "integrity": "sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw==", "cpu": [ "x64" ], @@ -1126,9 +1089,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.4.tgz", - "integrity": "sha512-dtBZYjDmCQ9hW+WgEkaffvRRCKm767wWhxsFW3Lw86VXz/uJRuD438/XvbZT//B96Vs8oTA8Q4A0AfHbrxP9zw==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz", + "integrity": "sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg==", "cpu": [ "x64" ], @@ -1140,9 +1103,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.4.tgz", - "integrity": "sha512-1ox+GqgRWqaB1RnyZXL8PD6E5f7YyRUJYnCqKpNzxzP0TkaUh112NDrR9Tt+C8rJ4x5G9Mk8PQR3o7Ku2RKqKA==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz", + "integrity": "sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg==", "cpu": [ "arm64" ], @@ -1154,9 +1117,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.4.tgz", - "integrity": "sha512-8GKr640PdFNXwzIE0IrkMWUNUomILLkfeHjXBi/nUvFlpZP+FA8BKGKpacjW6OUUHaNI6sUURxR2U2g78FOHWQ==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz", + "integrity": "sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA==", "cpu": [ "arm64" ], @@ -1168,9 +1131,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.4.tgz", - "integrity": "sha512-AIy/jdJ7WtJ/F6EcfOb2GjR9UweO0n43jNObQMb6oGxkYTfLcnN7vYYpG+CN3lLxrQkzWnMOoNSHTW54pgbVxw==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz", + "integrity": "sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w==", "cpu": [ "ia32" ], @@ -1182,9 +1145,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.4.tgz", - "integrity": "sha512-UF9KfsH9yEam0UjTwAgdK0anlQ7c8/pWPU2yVjyWcF1I1thABt6WXE47cI71pGiZ8wGvxohBoLnxM04L/wj8mQ==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz", + "integrity": "sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A==", "cpu": [ "x64" ], @@ -1196,9 +1159,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.4.tgz", - "integrity": "sha512-bf9PtUa0u8IXDVxzRToFQKsNCRz9qLYfR/MpECxl4mRoWYjAeFjgxj1XdZr2M/GNVpT05p+LgQOHopYDlUu6/w==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz", + "integrity": "sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ==", "cpu": [ "x64" ], @@ -1297,23 +1260,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.7.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz", - "integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==", + "version": "25.0.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz", + "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~7.14.0" - } - }, - "node_modules/@types/react": { - "version": "19.2.2", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", - "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.0.2" + "undici-types": "~7.16.0" } }, "node_modules/@types/yauzl": { @@ -1327,18 +1280,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz", - "integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==", + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.0.tgz", + "integrity": "sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.46.1", - "@typescript-eslint/type-utils": "8.46.1", - "@typescript-eslint/utils": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "graphemer": "^1.4.0", + "@typescript-eslint/scope-manager": "8.50.0", + "@typescript-eslint/type-utils": "8.50.0", + "@typescript-eslint/utils": "8.50.0", + "@typescript-eslint/visitor-keys": "8.50.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" @@ -1351,24 +1303,23 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.46.1", + "@typescript-eslint/parser": "^8.50.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz", - "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.53.0.tgz", + "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/typescript-estree": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1382,16 +1333,65 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.1.tgz", - "integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==", + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.53.0.tgz", + "integrity": "sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.46.1", - "@typescript-eslint/types": "^8.46.1", - "debug": "^4.3.4" + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.0.tgz", + "integrity": "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.53.0.tgz", + "integrity": "sha512-Bl6Gdr7NqkqIP5yP9z1JU///Nmes4Eose6L1HwpuVHwScgDPPuEWbUVhvlZmb8hy0vX9syLk5EGNL700WcBlbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.53.0", + "@typescript-eslint/types": "^8.53.0", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1405,14 +1405,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz", - "integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==", + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.50.0.tgz", + "integrity": "sha512-xCwfuCZjhIqy7+HKxBLrDVT5q/iq7XBVBXLn57RTIIpelLtEIZHXAF/Upa3+gaCpeV1NNS5Z9A+ID6jn50VD4A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1" + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/visitor-keys": "8.50.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1422,10 +1422,24 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/scope-manager/node_modules/@typescript-eslint/types": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.0.tgz", + "integrity": "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz", - "integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.53.0.tgz", + "integrity": "sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==", "dev": true, "license": "MIT", "engines": { @@ -1440,15 +1454,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz", - "integrity": "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==", + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.50.0.tgz", + "integrity": "sha512-7OciHT2lKCewR0mFoBrvZJ4AXTMe/sYOe87289WAViOocEmDjjv8MvIOT2XESuKj9jp8u3SZYUSh89QA4S1kQw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1", - "@typescript-eslint/utils": "8.46.1", + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/typescript-estree": "8.50.0", + "@typescript-eslint/utils": "8.50.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1464,10 +1478,91 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/project-service": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.0.tgz", + "integrity": "sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.50.0", + "@typescript-eslint/types": "^8.50.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.0.tgz", + "integrity": "sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.0.tgz", + "integrity": "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.0.tgz", + "integrity": "sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.50.0", + "@typescript-eslint/tsconfig-utils": "8.50.0", + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/visitor-keys": "8.50.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/@typescript-eslint/types": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz", - "integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.53.0.tgz", + "integrity": "sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==", "dev": true, "license": "MIT", "engines": { @@ -1479,22 +1574,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz", - "integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==", + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.53.0.tgz", + "integrity": "sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.46.1", - "@typescript-eslint/tsconfig-utils": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/visitor-keys": "8.46.1", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/project-service": "8.53.0", + "@typescript-eslint/tsconfig-utils": "8.53.0", + "@typescript-eslint/types": "8.53.0", + "@typescript-eslint/visitor-keys": "8.53.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1507,17 +1601,48 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.53.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.53.0.tgz", + "integrity": "sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.53.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@typescript-eslint/utils": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.1.tgz", - "integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==", + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.50.0.tgz", + "integrity": "sha512-87KgUXET09CRjGCi2Ejxy3PULXna63/bMYv72tCAlDJC3Yqwln0HiFJ3VJMst2+mEtNtZu5oFvX4qJGjKsnAgg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.46.1", - "@typescript-eslint/types": "8.46.1", - "@typescript-eslint/typescript-estree": "8.46.1" + "@typescript-eslint/scope-manager": "8.50.0", + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/typescript-estree": "8.50.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1531,14 +1656,95 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.46.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz", - "integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==", + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/project-service": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.50.0.tgz", + "integrity": "sha512-Cg/nQcL1BcoTijEWyx4mkVC56r8dj44bFDvBdygifuS20f3OZCHmFbjF34DPSi07kwlFvqfv/xOLnJ5DquxSGQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.46.1", + "@typescript-eslint/tsconfig-utils": "^8.50.0", + "@typescript-eslint/types": "^8.50.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.50.0.tgz", + "integrity": "sha512-vxd3G/ybKTSlm31MOA96gqvrRGv9RJ7LGtZCn2Vrc5htA0zCDvcMqUkifcjrWNNKXHUU3WCkYOzzVSFBd0wa2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.0.tgz", + "integrity": "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.50.0.tgz", + "integrity": "sha512-W7SVAGBR/IX7zm1t70Yujpbk+zdPq/u4soeFSknWFdXIFuWsBGBOUu/Tn/I6KHSKvSh91OiMuaSnYp3mtPt5IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.50.0", + "@typescript-eslint/tsconfig-utils": "8.50.0", + "@typescript-eslint/types": "8.50.0", + "@typescript-eslint/visitor-keys": "8.50.0", + "debug": "^4.3.4", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.50.0.tgz", + "integrity": "sha512-Xzmnb58+Db78gT/CCj/PVCvK+zxbnsw6F+O1oheYszJbBSdEjVhQi3C/Xttzxgi/GLmpvOggRs1RFpiJ8+c34Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.50.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -1549,6 +1755,20 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/@typescript-eslint/types": { + "version": "8.50.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.50.0.tgz", + "integrity": "sha512-iX1mgmGrXdANhhITbpp2QQM2fGehBse9LbTf0sidWK6yg/NE+uhV5dfU1g6EYPlcReYmkE9QLPq/2irKAmtS9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", @@ -1837,6 +2057,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1880,19 +2101,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -2070,9 +2278,9 @@ } }, "node_modules/async-wait-until": { - "version": "2.0.30", - "resolved": "https://registry.npmjs.org/async-wait-until/-/async-wait-until-2.0.30.tgz", - "integrity": "sha512-WZjoOcRIN9SY0wve4SvJ1qae0fKomq/tEQtCzSQ6XR9+VqF0Ye5cGHfK2xR8RR6zE5z1Is2M6qjXNJPMnsxCXA==", + "version": "2.0.31", + "resolved": "https://registry.npmjs.org/async-wait-until/-/async-wait-until-2.0.31.tgz", + "integrity": "sha512-9VCfHvc4f36oT6sG5p16aKc9zojf3wF4FrjNDxU3Db51SJ1bQ5lWAWtQDDZPysTwSLKBDzNZ083qPkTIj6XnrA==", "license": "MIT", "engines": { "node": ">= 0.14.0", @@ -2115,9 +2323,9 @@ "license": "MIT" }, "node_modules/basic-ftp": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", - "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.1.0.tgz", + "integrity": "sha512-RkaJzeJKDbaDWTIPiJwubyljaEPwpVWkm9Rt5h9Nd6h7tEXTJ3VB4qxdZBioV7JO5yLUaOKwz7vDOzlncUsegw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -2333,13 +2541,6 @@ "node": ">= 8" } }, - "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, - "license": "MIT" - }, "node_modules/data-uri-to-buffer": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", @@ -2404,9 +2605,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.18", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", - "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", "dev": true, "license": "MIT" }, @@ -2546,20 +2747,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -2579,9 +2766,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", "dev": true, "license": "MIT", "dependencies": { @@ -2762,25 +2949,25 @@ } }, "node_modules/eslint": { - "version": "9.37.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", - "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.4.0", - "@eslint/core": "^0.16.0", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.37.0", - "@eslint/plugin-kit": "^0.4.0", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", @@ -2948,6 +3135,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3142,9 +3330,9 @@ } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3277,9 +3465,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastq": { - "version": "1.19.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", - "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -3294,6 +3482,24 @@ "pend": "~1.2.0" } }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3373,23 +3579,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -3549,9 +3738,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.12.0.tgz", - "integrity": "sha512-LScr2aNr2FbjAjZh2C6X6BxRx1/x+aTDExct/xyq2XKbYOiG5c0aK7pMsSuyc0brz3ibr/lbQiHD9jzt4lccJw==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", + "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3576,22 +3765,16 @@ } }, "node_modules/glob": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", - "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", + "minimatch": "^10.1.1", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, "engines": { "node": "20 || >=22" }, @@ -3613,11 +3796,11 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/brace-expansion": "^5.0.0" }, @@ -3754,13 +3937,6 @@ "dev": true, "license": "ISC" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -3965,9 +4141,9 @@ } }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", "engines": { "node": ">= 12" @@ -4149,16 +4325,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -4411,22 +4577,6 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, - "node_modules/jackspeak": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", - "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4434,9 +4584,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4549,11 +4699,11 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", - "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } @@ -4600,6 +4750,18 @@ "node": ">=8.6" } }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -4610,15 +4772,19 @@ } }, "node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/minimatch": { @@ -4901,13 +5067,6 @@ "node": ">= 14" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, "node_modules/pako": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", @@ -4980,9 +5139,9 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", - "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -5024,24 +5183,25 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/playwright": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0.tgz", - "integrity": "sha512-X5Q1b8lOdWIE4KAoHpW3SE8HvUB+ZZsUoN64ZhjnN8dOb1UpujxBtENGiZFE+9F/yhzJwYa+ca3u43FeLbboHA==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", + "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.56.0" + "playwright-core": "1.57.0" }, "bin": { "playwright": "cli.js" @@ -5054,10 +5214,11 @@ } }, "node_modules/playwright-core": { - "version": "1.56.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0.tgz", - "integrity": "sha512-1SXl7pMfemAMSDn5rkPeZljxOCYAmQnYLBTExuh6E8USHXGSX3dx6lYZN/xPpTz1vimXmPA9CDnILvmJaB8aSQ==", + "version": "1.57.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", + "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -5086,9 +5247,9 @@ } }, "node_modules/prettier": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", - "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", + "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", "bin": { @@ -5204,13 +5365,13 @@ } }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -5313,11 +5474,12 @@ } }, "node_modules/rollup": { - "version": "4.52.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz", - "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", + "version": "4.53.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.5.tgz", + "integrity": "sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -5329,28 +5491,28 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.52.4", - "@rollup/rollup-android-arm64": "4.52.4", - "@rollup/rollup-darwin-arm64": "4.52.4", - "@rollup/rollup-darwin-x64": "4.52.4", - "@rollup/rollup-freebsd-arm64": "4.52.4", - "@rollup/rollup-freebsd-x64": "4.52.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.52.4", - "@rollup/rollup-linux-arm-musleabihf": "4.52.4", - "@rollup/rollup-linux-arm64-gnu": "4.52.4", - "@rollup/rollup-linux-arm64-musl": "4.52.4", - "@rollup/rollup-linux-loong64-gnu": "4.52.4", - "@rollup/rollup-linux-ppc64-gnu": "4.52.4", - "@rollup/rollup-linux-riscv64-gnu": "4.52.4", - "@rollup/rollup-linux-riscv64-musl": "4.52.4", - "@rollup/rollup-linux-s390x-gnu": "4.52.4", - "@rollup/rollup-linux-x64-gnu": "4.52.4", - "@rollup/rollup-linux-x64-musl": "4.52.4", - "@rollup/rollup-openharmony-arm64": "4.52.4", - "@rollup/rollup-win32-arm64-msvc": "4.52.4", - "@rollup/rollup-win32-ia32-msvc": "4.52.4", - "@rollup/rollup-win32-x64-gnu": "4.52.4", - "@rollup/rollup-win32-x64-msvc": "4.52.4", + "@rollup/rollup-android-arm-eabi": "4.53.5", + "@rollup/rollup-android-arm64": "4.53.5", + "@rollup/rollup-darwin-arm64": "4.53.5", + "@rollup/rollup-darwin-x64": "4.53.5", + "@rollup/rollup-freebsd-arm64": "4.53.5", + "@rollup/rollup-freebsd-x64": "4.53.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.53.5", + "@rollup/rollup-linux-arm-musleabihf": "4.53.5", + "@rollup/rollup-linux-arm64-gnu": "4.53.5", + "@rollup/rollup-linux-arm64-musl": "4.53.5", + "@rollup/rollup-linux-loong64-gnu": "4.53.5", + "@rollup/rollup-linux-ppc64-gnu": "4.53.5", + "@rollup/rollup-linux-riscv64-gnu": "4.53.5", + "@rollup/rollup-linux-riscv64-musl": "4.53.5", + "@rollup/rollup-linux-s390x-gnu": "4.53.5", + "@rollup/rollup-linux-x64-gnu": "4.53.5", + "@rollup/rollup-linux-x64-musl": "4.53.5", + "@rollup/rollup-openharmony-arm64": "4.53.5", + "@rollup/rollup-win32-arm64-msvc": "4.53.5", + "@rollup/rollup-win32-ia32-msvc": "4.53.5", + "@rollup/rollup-win32-x64-gnu": "4.53.5", + "@rollup/rollup-win32-x64-msvc": "4.53.5", "fsevents": "~2.3.2" } }, @@ -5628,19 +5790,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -5723,70 +5872,6 @@ "node": ">= 0.4" } }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string.prototype.trim": { "version": "1.2.10", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", @@ -5846,46 +5931,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -5936,9 +5981,9 @@ } }, "node_modules/systeminformation": { - "version": "5.27.11", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.27.11.tgz", - "integrity": "sha512-K3Lto/2m3K2twmKHdgx5B+0in9qhXK4YnoT9rIlgwN/4v7OV5c8IjbeAUkuky/6VzCQC7iKCAqi8rZathCdjHg==", + "version": "5.30.4", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.30.4.tgz", + "integrity": "sha512-6Zi6NZRuEnK8Uv8R5s6+iz2NvamrxpYdpxhF7ANpzjlTfDRPQEJJh1cz2Car5KT+L1EWv6zGzECITKTinfL47g==", "license": "MIT", "os": [ "darwin", @@ -5978,37 +6023,6 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6022,9 +6036,9 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "dev": true, "license": "MIT", "engines": { @@ -6150,6 +6164,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6178,9 +6193,9 @@ } }, "node_modules/undici-types": { - "version": "7.14.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", - "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "devOptional": true, "license": "MIT" }, @@ -6201,6 +6216,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -6335,9 +6351,9 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -6366,101 +6382,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -6468,9 +6389,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -6489,15 +6410,18 @@ } }, "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yauzl": { @@ -6524,9 +6448,9 @@ } }, "node_modules/zod": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", - "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", + "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "dev": true, "license": "MIT", "funding": { diff --git a/e2e-tests/playwright/package.json b/e2e-tests/playwright/package.json index 0e3cbb7ca2f..75a58699f8d 100644 --- a/e2e-tests/playwright/package.json +++ b/e2e-tests/playwright/package.json @@ -32,19 +32,19 @@ "@mattermost/types": "file:../../webapp/platform/types" }, "devDependencies": { - "@playwright/test": "1.56.0", + "@playwright/test": "1.57.0", "@types/luxon": "3.7.1", - "@typescript-eslint/eslint-plugin": "8.46.1", + "@typescript-eslint/eslint-plugin": "8.50.0", "cross-env": "10.1.0", - "dayjs": "1.11.18", - "eslint": "9.37.0", + "dayjs": "1.11.19", + "eslint": "9.39.2", "eslint-import-resolver-typescript": "4.4.4", "eslint-plugin-header": "3.1.1", "eslint-plugin-import": "2.32.0", - "glob": "11.0.3", + "glob": "13.0.0", "luxon": "3.7.2", - "prettier": "3.6.2", + "prettier": "3.7.4", "typescript": "5.9.3", - "zod": "4.1.12" + "zod": "4.2.1" } } diff --git a/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/custom_attributes.spec.ts b/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/custom_attributes.spec.ts index b098c86aed1..b57116b3435 100644 --- a/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/custom_attributes.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/custom_attributes.spec.ts @@ -76,7 +76,7 @@ test.beforeEach(async ({pw}) => { await pw.skipIfNoLicense(); // Initialize with admin client - ({team, user, adminClient, userClient} = await pw.initSetup({userPrefix: 'cpa-test-'})); + ({team, user, adminClient, userClient} = await pw.initSetup({userOptions: {prefix: 'cpa-test-'}})); const channel = pw.random.channel({ teamId: team.id, name: `test-channel`, @@ -85,7 +85,7 @@ test.beforeEach(async ({pw}) => { testChannel = await adminClient.createChannel(channel); // Create another user to test profile popover - otherUser = await pw.createNewUserProfile(adminClient, 'cpa-other-'); + otherUser = await pw.createNewUserProfile(adminClient, {prefix: 'cpa-other-'}); await adminClient.addToTeam(team.id, otherUser.id); await adminClient.addToChannel(otherUser.id, testChannel.id); diff --git a/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/user_settings.spec.ts b/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/user_settings.spec.ts index 5d01eb93a43..0bd7124b957 100644 --- a/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/user_settings.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/user_settings.spec.ts @@ -85,7 +85,7 @@ test.beforeEach(async ({pw}) => { await pw.skipIfNoLicense(); // Initialize with admin client - ({team, user, adminClient, userClient} = await pw.initSetup({userPrefix: 'cpa-test-'})); + ({team, user, adminClient, userClient} = await pw.initSetup({userOptions: {prefix: 'cpa-test-'}})); const channel = pw.random.channel({ teamId: team.id, name: `test-channel`, @@ -94,7 +94,7 @@ test.beforeEach(async ({pw}) => { testChannel = await adminClient.createChannel(channel); // Create another user to test profile popover - otherUser = await pw.createNewUserProfile(adminClient, 'cpa-other-'); + otherUser = await pw.createNewUserProfile(adminClient, {prefix: 'cpa-other-'}); await adminClient.addToTeam(team.id, otherUser.id); await adminClient.addToChannel(otherUser.id, testChannel.id); diff --git a/e2e-tests/playwright/specs/functional/channels/edit_file_attachments/edit_file_attachment.spec.ts b/e2e-tests/playwright/specs/functional/channels/edit_file_attachments/edit_file_attachment.spec.ts index 40f46722fed..551a0e8124d 100644 --- a/e2e-tests/playwright/specs/functional/channels/edit_file_attachments/edit_file_attachment.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/edit_file_attachments/edit_file_attachment.spec.ts @@ -3,7 +3,7 @@ import {Page} from '@playwright/test'; -import {expect, test} from '@mattermost/playwright-lib'; +import {test} from '@mattermost/playwright-lib'; test('MM-T5654_1 should be able to add attachments while editing a post', async ({pw}) => { const originalMessage = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit'; @@ -107,8 +107,8 @@ test('MM-T5654_2 should be able to add attachments while editing a threaded post updatedReplyPost = await channelsPage.sidebarRight.getLastPost(); await updatedReplyPost.toBeVisible(); await updatedReplyPost.toContainText('Edited reply message with files'); - expect(updatedReplyPost).not.toContain('sample_text_file.txt'); - expect(updatedReplyPost).not.toContain('mattermost.png'); + await updatedReplyPost.toNotContainText('sample_text_file.txt'); + await updatedReplyPost.toNotContainText('mattermost.png'); }); test('MM-T5654_3 should be able to edit post message originally containing files', async ({pw}) => { @@ -217,7 +217,7 @@ test('MM-5654_5 should be able to remove attachments while editing a post', asyn await updatedPost.toContainText(originalMessage); await updatedPost.toContainText('mattermost.png'); await updatedPost.toContainText('archive.zip'); - expect(updatedPost).not.toContain('archive.zip'); + await updatedPost.toNotContainText('sample_text_file.txt'); }); test('MM-T5655_1 removing message content and files should delete the post', async ({pw}) => { @@ -250,8 +250,8 @@ test('MM-T5655_1 removing message content and files should delete the post', asy await channelsPage.centerView.postEdit.deleteConfirmationDialog.confirmDeletion(); await channelsPage.centerView.postEdit.deleteConfirmationDialog.notToBeVisible(); - expect(channelsPage).not.toContain(originalMessage); - expect(channelsPage).not.toContain('sample_text_file.txt'); + await channelsPage.toNotContainText(originalMessage); + await channelsPage.toNotContainText('sample_text_file.txt'); }); test('MM-T5655_2 should be able to remove all files when editing a post', async ({pw}) => { @@ -287,9 +287,9 @@ test('MM-T5655_2 should be able to remove all files when editing a post', async const updatedPost = await channelsPage.getLastPost(); await updatedPost.toBeVisible(); await updatedPost.toContainText(originalMessage); - expect(updatedPost).not.toContain('archive.zip'); - expect(updatedPost).not.toContain('mattermost.png'); - expect(updatedPost).not.toContain('sample_text_file.txt'); + await updatedPost.toNotContainText('archive.zip'); + await updatedPost.toNotContainText('mattermost.png'); + await updatedPost.toNotContainText('sample_text_file.txt'); }); test('MM-T5656_1 should be able to restore previously edited post version that contains attachments', async ({pw}) => { @@ -322,7 +322,7 @@ test('MM-T5656_1 should be able to restore previously edited post version that c const updatedPost = await channelsPage.getLastPost(); await updatedPost.toBeVisible(); await updatedPost.toContainText(newMessage); - expect(updatedPost).not.toContain('sample_text_file.txt'); + await updatedPost.toNotContainText('sample_text_file.txt'); const postID = await channelsPage.centerView.getLastPostID(); await channelsPage.centerView.clickOnLastEditedPost(postID); @@ -338,7 +338,7 @@ test('MM-T5656_1 should be able to restore previously edited post version that c const restoredPost = await channelsPage.getLastPost(); await restoredPost.toBeVisible(); - expect(restoredPost.toContainText('sample_text_file.txt')); + await restoredPost.toContainText('sample_text_file.txt'); }); async function moveMouseToCenter(page: Page) { diff --git a/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/shift_up_shortcut.spec.ts b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/shift_up_shortcut.spec.ts new file mode 100644 index 00000000000..3deeb98c8df --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/keyboard_shortcuts/shift_up_shortcut.spec.ts @@ -0,0 +1,90 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@mattermost/playwright-lib'; + +/** + * @objective Verify that pressing Shift+Up in the textbox in center channel opens the thread for the last post in RHS + * and correctly focuses the reply textbox, even when there are large messages with attachments from other users. + */ +test( + 'Keyboard shortcuts Shift+Up on center textbox opens the last post in the RHS and correctly focuses the reply textbox', + {tag: '@keyboard_shortcuts'}, + async ({pw}, testInfo) => { + const ROOT_MESSAGE = 'The root message for testing Shift+Up keyboard shortcut'; + const NUMBER_OF_REPLIES = 10; + const ATTACHMENT_FILES = ['mattermost.png', 'sample_text_file.txt', 'archive.zip']; + + test.skip(testInfo.project.name === 'ipad', 'Skipping test on iPad'); + + // # Initialize setup with admin and user + const {adminUser, user, team} = await pw.initSetup(); + + // # Log in as admin in one browser session + const {channelsPage: adminChannelsPage} = await pw.testBrowser.login(adminUser); + await adminChannelsPage.goto(team.name, 'town-square'); + await adminChannelsPage.toBeVisible(); + + // # Have admin post the root message for the thread + await adminChannelsPage.centerView.postCreate.postMessage(ROOT_MESSAGE); + + // # Have admin open the thread and post multiple replies with attachments + const rootPost = await adminChannelsPage.getLastPost(); + await rootPost.hover(); + await rootPost.postMenu.toBeVisible(); + await rootPost.postMenu.reply(); + + // * Verify RHS is visible for admin + await adminChannelsPage.sidebarRight.toBeVisible(); + + // # Firstly let admin create a series of random replies to the root message + for (let i = 1; i <= NUMBER_OF_REPLIES; i++) { + await adminChannelsPage.sidebarRight.postCreate.postMessage(`Random replies number ${i}`.repeat(40)); + } + + // # Secondly let admin create a series of random replies to the root message with attachments + for (const file of ATTACHMENT_FILES) { + await adminChannelsPage.sidebarRight.postCreate.postMessage( + `Random replies number with attachment: ${file}`, + [file], + ); + } + + // # Admin closes the RHS + await adminChannelsPage.sidebarRight.close(); + + // # Log in as regular user in a separate browser session + const {channelsPage: userChannelsPage, page: userPage} = await pw.testBrowser.login(user); + await userChannelsPage.goto(team.name, 'town-square'); + await userChannelsPage.toBeVisible(); + + // # Bring focus to the post textbox in center channel + await userChannelsPage.centerView.postCreate.input.focus(); + + // * Verify the post textbox in center channel is focused + await expect(userChannelsPage.centerView.postCreate.input).toBeFocused(); + + // # Press Shift+Up to open the latest thread in the channel in the RHS + await userPage.keyboard.press('Shift+ArrowUp'); + + // * Verify RHS is visible + await userChannelsPage.sidebarRight.toBeVisible(); + + // * Verify the correct thread (admin's root message) is shown in RHS + await userChannelsPage.sidebarRight.toContainText(ROOT_MESSAGE); + + // * Verify RHS reply textbox is focused only + await expect(userChannelsPage.sidebarRight.postCreate.input).toBeFocused(); + + // # Type a message to verify the textbox can receive input immediately + await userPage.keyboard.type('Reply typed after Shift+Up'); + + // * Verify the message was typed into the RHS textbox + const inputValue = await userChannelsPage.sidebarRight.postCreate.getInputValue(); + expect(inputValue).toBe('Reply typed after Shift+Up'); + + // # Clear the input and close RHS + await userChannelsPage.sidebarRight.postCreate.input.clear(); + await userChannelsPage.sidebarRight.close(); + }, +); diff --git a/e2e-tests/playwright/specs/functional/channels/mentions/multiple_mentions.spec.ts b/e2e-tests/playwright/specs/functional/channels/mentions/multiple_mentions.spec.ts index 4ffb8d8976c..1221b6b303f 100644 --- a/e2e-tests/playwright/specs/functional/channels/mentions/multiple_mentions.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/mentions/multiple_mentions.spec.ts @@ -14,13 +14,7 @@ test('displays multiple mentions correctly in Recent Mentions panel', {tag: '@me const MENTION_COUNT = 20; // # Initialize the first user who will create the mentions - const { - team, - user: mentioningUser, - userClient, - } = await pw.initSetup({ - userPrefix: 'mentioner', - }); + const {team, user: mentioningUser, userClient} = await pw.initSetup({userOptions: {prefix: 'mentioner'}}); // # Create a second user to be mentioned const {adminClient} = await pw.getAdminClient(); diff --git a/e2e-tests/playwright/specs/functional/system_console/permissions/team_access.spec.ts b/e2e-tests/playwright/specs/functional/system_console/permissions/team_access.spec.ts index 9addfa88c11..aeaeedffdd6 100644 --- a/e2e-tests/playwright/specs/functional/system_console/permissions/team_access.spec.ts +++ b/e2e-tests/playwright/specs/functional/system_console/permissions/team_access.spec.ts @@ -173,5 +173,12 @@ test('MM-63378 System Manager without team access permissions cannot view team d await expect(teamStatsHeading).toBeVisible(); // Verify the user has no API access to the otherTeam. - await expect(systemManagerClient.getTeam(otherTeam.id)).rejects.toThrow(); + let apiError: Error | null = null; + try { + await systemManagerClient.getTeam(otherTeam.id); + } catch (error) { + apiError = error as Error; + } + expect(apiError).not.toBeNull(); + expect(apiError?.message).toContain('You do not have the appropriate permissions'); }); diff --git a/e2e-tests/playwright/specs/functional/system_console/system_users/user_attributes_admin_editing.spec.ts b/e2e-tests/playwright/specs/functional/system_console/system_users/user_attributes_admin_editing.spec.ts index c0d26d768e1..5b21d23cef5 100644 --- a/e2e-tests/playwright/specs/functional/system_console/system_users/user_attributes_admin_editing.spec.ts +++ b/e2e-tests/playwright/specs/functional/system_console/system_users/user_attributes_admin_editing.spec.ts @@ -99,7 +99,7 @@ test.describe('System Console - Admin User Profile Editing', () => { ({team, adminUser, adminClient} = await pw.initSetup()); // Create test user to edit - testUser = await pw.createNewUserProfile(adminClient, 'admin-edit-target-'); + testUser = await pw.createNewUserProfile(adminClient, {prefix: 'admin-edit-target-'}); await adminClient.addToTeam(team.id, testUser.id); // Set up custom user attribute fields diff --git a/e2e-tests/playwright/specs/visual/channels/archived_channel_icons.spec.ts b/e2e-tests/playwright/specs/visual/channels/archived_channel_icons.spec.ts new file mode 100644 index 00000000000..13e17eff948 --- /dev/null +++ b/e2e-tests/playwright/specs/visual/channels/archived_channel_icons.spec.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {expect, test} from '@mattermost/playwright-lib'; + +/** + * @objective Verify archived channel icons display correctly for public and private channels in various UI contexts + */ +test( + 'displays archive icons for public and private channels in sidebar', + {tag: ['@visual', '@archived_channels', '@snapshots']}, + async ({pw, browserName, viewport}, testInfo) => { + // # Initialize setup and create test channels + const {team, user, adminClient} = await pw.initSetup(); + + // # Create public and private channels + const publicChannel = await adminClient.createChannel( + pw.random.channel({ + teamId: team.id, + name: 'public-to-archive', + displayName: 'Public Archive Test', + type: 'O', + }), + ); + + const privateChannel = await adminClient.createChannel( + pw.random.channel({ + teamId: team.id, + name: 'private-to-archive', + displayName: 'Private Archive Test', + type: 'P', + }), + ); + + // # Archive both channels + await adminClient.deleteChannel(publicChannel.id); + await adminClient.deleteChannel(privateChannel.id); + + // # Log in user + const {page, channelsPage} = await pw.testBrowser.login(user); + + // # Visit town square to ensure we're in a stable state + await channelsPage.goto(team.name, 'town-square'); + await channelsPage.toBeVisible(); + + // # Open browse channels modal to show archived channels + await page.keyboard.press('Control+K'); + await page.waitForTimeout(500); + + // # Type to search for archived channels + await page.keyboard.type('archive'); + await page.waitForTimeout(500); + + // # Hide dynamic content + await pw.hideDynamicChannelsContent(page); + + // * Verify channel switcher shows both archived channels with correct icons + const testArgs = {page, browserName, viewport}; + await pw.matchSnapshot(testInfo, testArgs); + }, +); + +/** + * @objective Verify archived channel icons display correctly in admin console channel list + */ +test( + 'displays archive icons in admin console channel list', + {tag: ['@visual', '@archived_channels', '@admin_console', '@snapshots']}, + async ({pw, browserName, viewport}, testInfo) => { + // # Initialize setup with admin user + const {team, adminUser, adminClient} = await pw.initSetup(); + + // # Create public and private channels + const publicChannel = await adminClient.createChannel( + pw.random.channel({ + teamId: team.id, + name: 'admin-public-archive', + displayName: 'Admin Public Archive', + type: 'O', + }), + ); + + const privateChannel = await adminClient.createChannel( + pw.random.channel({ + teamId: team.id, + name: 'admin-private-archive', + displayName: 'Admin Private Archive', + type: 'P', + }), + ); + + // # Archive both channels + await adminClient.deleteChannel(publicChannel.id); + await adminClient.deleteChannel(privateChannel.id); + + // # Log in as admin + const {page} = await pw.testBrowser.login(adminUser); + + // # Navigate to admin console channels list + await page.goto('/admin_console/user_management/channels'); + await page.waitForTimeout(1000); + + // # Wait for channel list to load + await expect(page.locator('.DataGrid')).toBeVisible({timeout: 10000}); + + // # Search for our test channels to bring them into view + await page.fill('[data-testid="searchInput"]', 'Admin'); + await page.waitForTimeout(500); + + // * Verify both archived channels are visible with correct icons + const testArgs = {page, browserName, viewport}; + await pw.matchSnapshot(testInfo, testArgs); + }, +); + +/** + * @objective Verify archived private channel icon displays in channel header when viewing archived channel + */ +test( + 'displays archive icon in channel header for archived private channel', + {tag: ['@visual', '@archived_channels', '@channel_header', '@snapshots']}, + async ({pw, browserName, viewport}, testInfo) => { + // # Initialize setup + const {team, adminUser, adminClient} = await pw.initSetup(); + + // # Create a private channel + const privateChannel = await adminClient.createChannel( + pw.random.channel({ + teamId: team.id, + name: 'private-header-test', + displayName: 'Private Header Test', + type: 'P', + }), + ); + + // # Archive the channel + await adminClient.deleteChannel(privateChannel.id); + + const {page, channelsPage} = await pw.testBrowser.login(adminUser); + + // # Visit the archived channel directly + await channelsPage.goto(team.name, privateChannel.name); + + // # Wait for channel header to load (archived channels don't have post-create) + await expect(page.locator('.channel-header')).toBeVisible(); + + // # Verify archived channel message is visible + await expect(page.locator('#channelArchivedMessage')).toBeVisible(); + + // # Hide dynamic content + await pw.hideDynamicChannelsContent(page); + + // # Focus on channel header area for snapshot + const headerElement = page.locator('.channel-header'); + await expect(headerElement).toBeVisible(); + + // * Verify channel header shows archive-lock icon for private archived channel + const testArgs = {page, browserName, viewport}; + await pw.matchSnapshot(testInfo, testArgs); + }, +); diff --git a/enable-claude-docs.sh b/enable-claude-docs.sh new file mode 100755 index 00000000000..9d6a05dada3 --- /dev/null +++ b/enable-claude-docs.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# This script enables the optional Claude documentation by copying +# CLAUDE.OPTIONAL.md files to CLAUDE.md. +# CLAUDE.md files are gitignored, so they act as local-only documentation. + +echo "Enabling Claude documentation..." + +find . -name "CLAUDE.OPTIONAL.md" -not -path "*/node_modules/*" | while read -r file; do + target_file="${file%.OPTIONAL.md}.md" + echo "Copying $file to $target_file" + cp "$file" "$target_file" +done + +echo "Done! CLAUDE.md files are now active (and ignored by git). *NOTE: Re-running this script will overwrite any changes you'd made to the CLAUDE.md files. If you have an improvement, please change the relevant CLAUDE.OPTIONAL.md file instead, and submit a PR." + diff --git a/server/.go-version b/server/.go-version index 7a429d68a36..d6c68ad2d09 100644 --- a/server/.go-version +++ b/server/.go-version @@ -1 +1 @@ -1.24.6 +1.24.11 diff --git a/server/.golangci.yml b/server/.golangci.yml index 876468f137d..a5eeddc21d8 100644 --- a/server/.golangci.yml +++ b/server/.golangci.yml @@ -41,9 +41,6 @@ linters: - linters: - misspell path: platform/shared/markdown/html_entities.go - - linters: - - unqueryvet - path: channels/store/sqlstore/post_store.go - linters: - staticcheck text: SA1019 diff --git a/server/Makefile b/server/Makefile index ec442c74129..2ed636180dd 100644 --- a/server/Makefile +++ b/server/Makefile @@ -155,12 +155,12 @@ PLUGIN_PACKAGES ?= $(PLUGIN_PACKAGES:) PLUGIN_PACKAGES += mattermost-plugin-calls-v1.11.0 PLUGIN_PACKAGES += mattermost-plugin-github-v2.5.0 PLUGIN_PACKAGES += mattermost-plugin-gitlab-v1.11.0 -PLUGIN_PACKAGES += mattermost-plugin-jira-v4.5.0 +PLUGIN_PACKAGES += mattermost-plugin-jira-v4.5.1 PLUGIN_PACKAGES += mattermost-plugin-playbooks-v2.6.1 PLUGIN_PACKAGES += mattermost-plugin-servicenow-v2.4.0 PLUGIN_PACKAGES += mattermost-plugin-zoom-v1.11.0 PLUGIN_PACKAGES += mattermost-plugin-agents-v1.7.2 -PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.1 +PLUGIN_PACKAGES += mattermost-plugin-boards-v9.2.2 PLUGIN_PACKAGES += mattermost-plugin-user-survey-v1.1.1 PLUGIN_PACKAGES += mattermost-plugin-mscalendar-v1.5.0 PLUGIN_PACKAGES += mattermost-plugin-msteams-meetings-v2.3.0 @@ -789,7 +789,18 @@ endif vet: ## Run mattermost go vet specific checks $(GO) install github.com/mattermost/mattermost-govet/v2@7d8db289e508999dfcac47b97c9490a0fec12d66 - $(GO) vet -vettool=$(GOBIN)/mattermost-govet -structuredLogging -inconsistentReceiverName -emptyStrCmp -tFatal -configtelemetry -errorAssertions -requestCtxNaming -license -inconsistentReceiverName.ignore=session_serial_gen.go,team_member_serial_gen.go,user_serial_gen.go,utils_serial_gen.go ./... + $(GO) vet -vettool=$(GOBIN)/mattermost-govet \ + -structuredLogging \ + -inconsistentReceiverName \ + -emptyStrCmp \ + -tFatal \ + -configtelemetry \ + -errorAssertions \ + -requestCtxNaming \ + -license \ + -inconsistentReceiverName.ignore=session_serial_gen.go,team_member_serial_gen.go,user_serial_gen.go,utils_serial_gen.go \ + -noSelectStar \ + ./... ifeq ($(BUILD_ENTERPRISE_READY),true) ifneq ($(MM_NO_ENTERPRISE_LINT),true) $(GO) vet -vettool=$(GOBIN)/mattermost-govet -structuredLogging -inconsistentReceiverName -emptyStrCmp -tFatal -configtelemetry -errorAssertions -requestCtxNaming -enterpriseLicense $(BUILD_ENTERPRISE_DIR)/... diff --git a/server/build/Dockerfile.buildenv b/server/build/Dockerfile.buildenv index 1f43fd50c65..c737c5dd452 100644 --- a/server/build/Dockerfile.buildenv +++ b/server/build/Dockerfile.buildenv @@ -1,4 +1,4 @@ -FROM golang:1.24.6-bullseye@sha256:cf78ce8205287fdb2ca403aac77d68965c75734749e560c577c00e20ecb11954 +FROM mattermost/golang-bullseye:1.24.11@sha256:648e6d4bd76751787cf8eb2674942f931a01043872ce15ac9501382dabcefbe8 ARG NODE_VERSION=20.11.1 RUN apt-get update && apt-get install -y make git apt-transport-https ca-certificates curl software-properties-common build-essential zip xmlsec1 jq pgloader gnupg diff --git a/server/build/Dockerfile.buildenv-fips b/server/build/Dockerfile.buildenv-fips index 4e966130e6f..7d63b7aaec1 100644 --- a/server/build/Dockerfile.buildenv-fips +++ b/server/build/Dockerfile.buildenv-fips @@ -1,4 +1,4 @@ -FROM cgr.dev/mattermost.com/go-msft-fips:1.24.6-dev@sha256:53d076b1cfa53f8189c4723d813d711d92107c2e8b140805c71e39f4a06dc9cc +FROM cgr.dev/mattermost.com/go-msft-fips:1.24.11-dev@sha256:181a7db41bbff8cf0e522bd5f951a44f2a39a5f58ca930930dfbecdc6b690272 ARG NODE_VERSION=20.11.1 RUN apk add curl ca-certificates mailcap unrtf wv poppler-utils tzdata gpg xmlsec diff --git a/server/build/Dockerfile.fips b/server/build/Dockerfile.fips index 8e4390eccb0..2521cf245bc 100644 --- a/server/build/Dockerfile.fips +++ b/server/build/Dockerfile.fips @@ -1,5 +1,5 @@ # First stage - FIPS dev image with dependencies for building -FROM cgr.dev/mattermost.com/glibc-openssl-fips:15-dev@sha256:9223f9245fb026a3c255ce9b7028a069fe11432aa7710713a331eaa36f44851c AS builder +FROM cgr.dev/mattermost.com/glibc-openssl-fips:15-dev@sha256:ab5285209fff77fbe56e58aeed6d7f557cf74c6f90d1d8ee26053003f039b419 AS builder # Setting bash as our shell, and enabling pipefail option SHELL ["/bin/bash", "-o", "pipefail", "-c"] diff --git a/server/channels/api4/access_control.go b/server/channels/api4/access_control.go index d0faf8c4a6f..2d5d861c560 100644 --- a/server/channels/api4/access_control.go +++ b/server/channels/api4/access_control.go @@ -64,7 +64,7 @@ func createAccessControlPolicy(c *Context, w http.ResponseWriter, r *http.Reques return } - hasChannelPermission := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, policy.ID, model.PermissionManageChannelAccessRules) + hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, policy.ID, model.PermissionManageChannelAccessRules) if !hasChannelPermission { c.SetPermissionError(model.PermissionManageChannelAccessRules) return @@ -197,7 +197,7 @@ func checkExpression(c *Context, w http.ResponseWriter, r *http.Request) { } // SECURE: Check specific channel permission - hasChannelPermission := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules) + hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules) if !hasChannelPermission { c.SetPermissionError(model.PermissionManageChannelAccessRules) return @@ -245,7 +245,7 @@ func testExpression(c *Context, w http.ResponseWriter, r *http.Request) { } // SECURE: Check specific channel permission - hasChannelPermission := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules) + hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules) if !hasChannelPermission { c.SetPermissionError(model.PermissionManageChannelAccessRules) return @@ -321,7 +321,7 @@ func validateExpressionAgainstRequester(c *Context, w http.ResponseWriter, r *ht } // SECURE: Check specific channel permission - hasChannelPermission := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules) + hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules) if !hasChannelPermission { c.SetPermissionError(model.PermissionManageChannelAccessRules) return @@ -450,7 +450,7 @@ func setActiveStatus(c *Context, w http.ResponseWriter, r *http.Request) { hasManageSystemPermission := c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSystem) if !hasManageSystemPermission { for _, entry := range list.Entries { - hasChannelPermission := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, entry.ID, model.PermissionManageChannelAccessRules) + hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, entry.ID, model.PermissionManageChannelAccessRules) if !hasChannelPermission { c.SetPermissionError(model.PermissionManageChannelAccessRules) return @@ -661,7 +661,7 @@ func getFieldsAutocomplete(c *Context, w http.ResponseWriter, r *http.Request) { } // SECURE: Check specific channel permission - hasChannelPermission := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules) + hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules) if !hasChannelPermission { c.SetPermissionError(model.PermissionManageChannelAccessRules) return @@ -735,7 +735,7 @@ func convertToVisualAST(c *Context, w http.ResponseWriter, r *http.Request) { } // SECURE: Check specific channel permission - hasChannelPermission := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules) + hasChannelPermission, _ := c.App.HasPermissionToChannel(c.AppContext, c.AppContext.Session().UserId, channelId, model.PermissionManageChannelAccessRules) if !hasChannelPermission { c.SetPermissionError(model.PermissionManageChannelAccessRules) return diff --git a/server/channels/api4/api.go b/server/channels/api4/api.go index 234b58eb1fe..5735b76324e 100644 --- a/server/channels/api4/api.go +++ b/server/channels/api4/api.go @@ -101,6 +101,8 @@ type Routes struct { Jobs *mux.Router // 'api/v4/jobs' + Recaps *mux.Router // 'api/v4/recaps' + Preferences *mux.Router // 'api/v4/users/{user_id:[A-Za-z0-9]+}/preferences' License *mux.Router // 'api/v4/license' @@ -256,6 +258,7 @@ func Init(srv *app.Server) (*API, error) { api.BaseRoutes.Public = api.BaseRoutes.APIRoot.PathPrefix("/public").Subrouter() api.BaseRoutes.Reactions = api.BaseRoutes.APIRoot.PathPrefix("/reactions").Subrouter() api.BaseRoutes.Jobs = api.BaseRoutes.APIRoot.PathPrefix("/jobs").Subrouter() + api.BaseRoutes.Recaps = api.BaseRoutes.APIRoot.PathPrefix("/recaps").Subrouter() api.BaseRoutes.Elasticsearch = api.BaseRoutes.APIRoot.PathPrefix("/elasticsearch").Subrouter() api.BaseRoutes.DataRetention = api.BaseRoutes.APIRoot.PathPrefix("/data_retention").Subrouter() @@ -337,6 +340,7 @@ func Init(srv *app.Server) (*API, error) { api.InitDataRetention() api.InitBrand() api.InitJob() + api.InitRecap() api.InitCommand() api.InitStatus() api.InitWebSocket() diff --git a/server/channels/api4/channel.go b/server/channels/api4/channel.go index cadc997e262..7cbd9607025 100644 --- a/server/channels/api4/channel.go +++ b/server/channels/api4/channel.go @@ -166,13 +166,13 @@ func updateChannel(c *Context, w http.ResponseWriter, r *http.Request) { switch oldChannel.Type { case model.ChannelTypeOpen: - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePublicChannelProperties) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePublicChannelProperties); !ok { c.SetPermissionError(model.PermissionManagePublicChannelProperties) return } case model.ChannelTypePrivate: - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePrivateChannelProperties) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePrivateChannelProperties); !ok { c.SetPermissionError(model.PermissionManagePrivateChannelProperties) return } @@ -277,14 +277,18 @@ func updateChannelPrivacy(c *Context, w http.ResponseWriter, r *http.Request) { auditRec.AddEventPriorState(channel) - if model.ChannelType(privacy) == model.ChannelTypeOpen && !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionConvertPrivateChannelToPublic) { - c.SetPermissionError(model.PermissionConvertPrivateChannelToPublic) - return + if model.ChannelType(privacy) == model.ChannelTypeOpen { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionConvertPrivateChannelToPublic); !ok { + c.SetPermissionError(model.PermissionConvertPrivateChannelToPublic) + return + } } - if model.ChannelType(privacy) == model.ChannelTypePrivate && !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionConvertPublicChannelToPrivate) { - c.SetPermissionError(model.PermissionConvertPublicChannelToPrivate) - return + if model.ChannelType(privacy) == model.ChannelTypePrivate { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionConvertPublicChannelToPrivate); !ok { + c.SetPermissionError(model.PermissionConvertPublicChannelToPrivate) + return + } } if channel.Name == model.DefaultChannelName && model.ChannelType(privacy) == model.ChannelTypePrivate { @@ -342,13 +346,13 @@ func patchChannel(c *Context, w http.ResponseWriter, r *http.Request) { switch oldChannel.Type { case model.ChannelTypeOpen: - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePublicChannelProperties) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePublicChannelProperties); !ok { c.SetPermissionError(model.PermissionManagePublicChannelProperties) return } case model.ChannelTypePrivate: - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePrivateChannelProperties) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePrivateChannelProperties); !ok { c.SetPermissionError(model.PermissionManagePrivateChannelProperties) return } @@ -664,15 +668,15 @@ func getChannel(c *Context, w http.ResponseWriter, r *http.Request) { if !isContentReviewer { if channel.Type == model.ChannelTypeOpen { - if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionReadPublicChannel) && !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) { - c.SetPermissionError(model.PermissionReadPublicChannel) - return - } - } else { - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) { - c.SetPermissionError(model.PermissionReadChannel) - return + if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionReadPublicChannel) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok { + c.SetPermissionError(model.PermissionReadChannel) + return + } } + } else if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok { + c.SetPermissionError(model.PermissionReadChannel) + return } } @@ -698,7 +702,7 @@ func getChannelUnread(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok { c.SetPermissionError(model.PermissionReadChannel) return } @@ -723,7 +727,7 @@ func getChannelStats(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok { c.SetPermissionError(model.PermissionReadChannel) return } @@ -813,7 +817,8 @@ func getPinnedPosts(c *Context, w http.ResponseWriter, r *http.Request) { c.Err = err return } - if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) { + var hasPermission, isMember bool + if hasPermission, isMember = c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel); !hasPermission { c.SetPermissionError(model.PermissionReadChannelContent) return } @@ -829,7 +834,7 @@ func getPinnedPosts(c *Context, w http.ResponseWriter, r *http.Request) { } clientPostList := c.App.PreparePostListForClient(c.AppContext, posts) - clientPostList, err = c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId) + clientPostList, isMemberForAllPreviews, err := c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId) if err != nil { c.Err = err return @@ -839,6 +844,14 @@ func getPinnedPosts(c *Context, w http.ResponseWriter, r *http.Request) { if err := clientPostList.EncodeJSON(w); err != nil { c.Logger.Warn("Error while writing response", mlog.Err(err)) } + + auditRec := c.MakeAuditRecord(model.AuditEventUpdateChannelMemberRoles, model.AuditStatusSuccess) + defer c.LogAuditRec(auditRec) + model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId) + + if !isMember || !isMemberForAllPreviews { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } } func getAllChannels(c *Context, w http.ResponseWriter, r *http.Request) { @@ -1035,7 +1048,7 @@ func getPublicChannelsByIdsForTeam(c *Context, w http.ResponseWriter, r *http.Re if session := c.AppContext.Session(); session.IsGuest() { for _, channel := range channels { - if !c.App.SessionHasPermissionToChannel(c.AppContext, *session, channel.Id, model.PermissionReadChannel) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *session, channel.Id, model.PermissionReadChannel); !ok { c.SetPermissionError(model.PermissionReadChannel) return } @@ -1387,14 +1400,18 @@ func deleteChannel(c *Context, w http.ResponseWriter, r *http.Request) { return } - if channel.Type == model.ChannelTypeOpen && !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionDeletePublicChannel) { - c.SetPermissionError(model.PermissionDeletePublicChannel) - return + if channel.Type == model.ChannelTypeOpen { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionDeletePublicChannel); !ok { + c.SetPermissionError(model.PermissionDeletePublicChannel) + return + } } - if channel.Type == model.ChannelTypePrivate && !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionDeletePrivateChannel) { - c.SetPermissionError(model.PermissionDeletePrivateChannel) - return + if channel.Type == model.ChannelTypePrivate { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionDeletePrivateChannel); !ok { + c.SetPermissionError(model.PermissionDeletePrivateChannel) + return + } } if c.Params.Permanent { @@ -1437,16 +1454,19 @@ func getChannelByName(c *Context, w http.ResponseWriter, r *http.Request) { } if channel.Type == model.ChannelTypeOpen { - if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionReadPublicChannel) && !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionReadChannel) { - c.SetPermissionError(model.PermissionReadPublicChannel) - return + if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionReadPublicChannel) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionReadChannel); !ok { + c.SetPermissionError(model.PermissionReadPublicChannel) + return + } } } else { // allows team admins to access private channel - if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionManageTeam) && - !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionReadChannel) { - c.Err = model.NewAppError("getChannelByName", "app.channel.get_by_name.missing.app_error", nil, "teamId="+channel.TeamId+", "+"name="+channel.Name+"", http.StatusNotFound) - return + if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionManageTeam) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionReadChannel); !ok { + c.Err = model.NewAppError("getChannelByName", "app.channel.get_by_name.missing.app_error", nil, "teamId="+channel.TeamId+", "+"name="+channel.Name+"", http.StatusNotFound) + return + } } } @@ -1474,7 +1494,7 @@ func getChannelByNameForTeamName(c *Context, w http.ResponseWriter, r *http.Requ return } - channelOk := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionReadChannel) + channelOk, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionReadChannel) if channel.Type == model.ChannelTypeOpen { teamOk := c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionReadPublicChannel) if !teamOk && !channelOk { @@ -1506,7 +1526,7 @@ func getChannelMembers(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok { c.SetPermissionError(model.PermissionReadChannel) return } @@ -1534,7 +1554,7 @@ func getChannelMembersTimezones(c *Context, w http.ResponseWriter, r *http.Reque return } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok { c.SetPermissionError(model.PermissionReadChannel) return } @@ -1565,7 +1585,7 @@ func getChannelMembersByIds(c *Context, w http.ResponseWriter, r *http.Request) return } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok { c.SetPermissionError(model.PermissionReadChannel) return } @@ -1593,7 +1613,7 @@ func getChannelMember(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok { c.SetPermissionError(model.PermissionReadChannel) return } @@ -1746,7 +1766,7 @@ func updateChannelMemberRoles(c *Context, w http.ResponseWriter, r *http.Request model.AddEventParameterToAuditRec(auditRec, "props", props) model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId) - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelRoles) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelRoles); !ok { c.SetPermissionError(model.PermissionManageChannelRoles) return } @@ -1778,7 +1798,7 @@ func updateChannelMemberSchemeRoles(c *Context, w http.ResponseWriter, r *http.R model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId) model.AddEventParameterAuditableToAuditRec(auditRec, "roles", &schemeRoles) - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelRoles) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelRoles); !ok { c.SetPermissionError(model.PermissionManageChannelRoles) return } @@ -1889,7 +1909,7 @@ func addChannelMember(c *Context, w http.ResponseWriter, r *http.Request) { // Security check: if the user is a guest, they must have access to the channel // to view its members if c.AppContext.Session().IsGuest() { - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) { + if hasPermission, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !hasPermission { c.SetPermissionError(model.PermissionReadChannel) return } @@ -1917,13 +1937,13 @@ func addChannelMember(c *Context, w http.ResponseWriter, r *http.Request) { if c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionJoinPublicChannels) { canAddSelf = true } - if c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePublicChannelMembers) { + if hasPermission, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePublicChannelMembers); hasPermission { canAddOthers = true } } if channel.Type == model.ChannelTypePrivate { - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePrivateChannelMembers) { + if hasPermission, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePrivateChannelMembers); !hasPermission { c.SetPermissionError(model.PermissionManagePrivateChannelMembers) return } @@ -2086,14 +2106,18 @@ func removeChannelMember(c *Context, w http.ResponseWriter, r *http.Request) { } if c.Params.UserId != c.AppContext.Session().UserId { - if channel.Type == model.ChannelTypeOpen && !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePublicChannelMembers) { - c.SetPermissionError(model.PermissionManagePublicChannelMembers) - return + if channel.Type == model.ChannelTypeOpen { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePublicChannelMembers); !ok { + c.SetPermissionError(model.PermissionManagePublicChannelMembers) + return + } } - if channel.Type == model.ChannelTypePrivate && !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePrivateChannelMembers) { - c.SetPermissionError(model.PermissionManagePrivateChannelMembers) - return + if channel.Type == model.ChannelTypePrivate { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePrivateChannelMembers); !ok { + c.SetPermissionError(model.PermissionManagePrivateChannelMembers) + return + } } } @@ -2235,7 +2259,7 @@ func channelMemberCountsByGroup(c *Context, w http.ResponseWriter, r *http.Reque return } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok { c.SetPermissionError(model.PermissionReadChannel) return } @@ -2457,7 +2481,7 @@ func getDirectOrGroupMessageMembersCommonTeams(c *Context, w http.ResponseWriter return } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok { c.SetPermissionError(model.PermissionReadChannel) return } @@ -2531,12 +2555,12 @@ func canEditChannelBanner(c *Context, originalChannel *model.Channel) { switch originalChannel.Type { case model.ChannelTypePrivate: - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePrivateChannelBanner) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePrivateChannelBanner); !ok { c.SetPermissionError(model.PermissionManagePrivateChannelBanner) return } case model.ChannelTypeOpen: - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePublicChannelBanner) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePublicChannelBanner); !ok { c.SetPermissionError(model.PermissionManagePublicChannelBanner) return } @@ -2551,7 +2575,7 @@ func getChannelAccessControlAttributes(c *Context, w http.ResponseWriter, r *htt return } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok { c.SetPermissionError(model.PermissionReadChannel) return } diff --git a/server/channels/api4/channel_bookmark.go b/server/channels/api4/channel_bookmark.go index 50046c8e861..3572d00e3a8 100644 --- a/server/channels/api4/channel_bookmark.go +++ b/server/channels/api4/channel_bookmark.go @@ -59,13 +59,13 @@ func createChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) { switch channel.Type { case model.ChannelTypeOpen: - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionAddBookmarkPublicChannel) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionAddBookmarkPublicChannel); !ok { c.SetPermissionError(model.PermissionAddBookmarkPublicChannel) return } case model.ChannelTypePrivate: - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionAddBookmarkPrivateChannel) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionAddBookmarkPrivateChannel); !ok { c.SetPermissionError(model.PermissionAddBookmarkPrivateChannel) return } @@ -158,18 +158,23 @@ func updateChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) { return } + isMember := false switch channel.Type { case model.ChannelTypeOpen: - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionEditBookmarkPublicChannel) { + ok, member := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionEditBookmarkPublicChannel) + if !ok { c.SetPermissionError(model.PermissionEditBookmarkPublicChannel) return } + isMember = member case model.ChannelTypePrivate: - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionEditBookmarkPrivateChannel) { + ok, member := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionEditBookmarkPrivateChannel) + if !ok { c.SetPermissionError(model.PermissionEditBookmarkPrivateChannel) return } + isMember = member case model.ChannelTypeGroup, model.ChannelTypeDirect: // Any member of DM/GMs but guests can manage channel bookmarks @@ -178,6 +183,7 @@ func updateChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) { return } + isMember = true user, gAppErr := c.App.GetUser(c.AppContext.Session().UserId) if gAppErr != nil { c.Err = gAppErr @@ -201,6 +207,10 @@ func updateChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) { return } + if !isMember { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } + auditRec.Success() auditRec.AddEventResultState(updateChannelBookmarkResponse) auditRec.AddEventObjectType("updateChannelBookmarkResponse") @@ -250,19 +260,22 @@ func updateChannelBookmarkSortOrder(c *Context, w http.ResponseWriter, r *http.R return } + isMember := false switch channel.Type { case model.ChannelTypeOpen: - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionOrderBookmarkPublicChannel) { + ok, member := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionOrderBookmarkPublicChannel) + if !ok { c.SetPermissionError(model.PermissionOrderBookmarkPublicChannel) return } - + isMember = member case model.ChannelTypePrivate: - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionOrderBookmarkPrivateChannel) { + ok, member := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionOrderBookmarkPrivateChannel) + if !ok { c.SetPermissionError(model.PermissionOrderBookmarkPrivateChannel) return } - + isMember = member case model.ChannelTypeGroup, model.ChannelTypeDirect: // Any member of DM/GMs but guests can manage channel bookmarks if _, errGet := c.App.GetChannelMember(c.AppContext, channel.Id, c.AppContext.Session().UserId); errGet != nil { @@ -270,6 +283,7 @@ func updateChannelBookmarkSortOrder(c *Context, w http.ResponseWriter, r *http.R return } + isMember = true user, gAppErr := c.App.GetUser(c.AppContext.Session().UserId) if gAppErr != nil { c.Err = gAppErr @@ -292,6 +306,10 @@ func updateChannelBookmarkSortOrder(c *Context, w http.ResponseWriter, r *http.R return } + if !isMember { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } + for _, b := range bookmarks { if b.Id == c.Params.ChannelBookmarkId { auditRec.AddEventResultState(b) @@ -335,19 +353,22 @@ func deleteChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) { return } + isMember := false switch channel.Type { case model.ChannelTypeOpen: - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionDeleteBookmarkPublicChannel) { + ok, member := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionDeleteBookmarkPublicChannel) + if !ok { c.SetPermissionError(model.PermissionDeleteBookmarkPublicChannel) return } - + isMember = member case model.ChannelTypePrivate: - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionDeleteBookmarkPrivateChannel) { + ok, member := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionDeleteBookmarkPrivateChannel) + if !ok { c.SetPermissionError(model.PermissionDeleteBookmarkPrivateChannel) return } - + isMember = member case model.ChannelTypeGroup, model.ChannelTypeDirect: // Any member of DM/GMs but guests can manage channel bookmarks if _, errGet := c.App.GetChannelMember(c.AppContext, channel.Id, c.AppContext.Session().UserId); errGet != nil { @@ -355,6 +376,7 @@ func deleteChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) { return } + isMember = true user, gAppErr := c.App.GetUser(c.AppContext.Session().UserId) if gAppErr != nil { c.Err = gAppErr @@ -390,6 +412,10 @@ func deleteChannelBookmark(c *Context, w http.ResponseWriter, r *http.Request) { return } + if !isMember { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } + auditRec.Success() auditRec.AddEventResultState(bookmark) c.LogAudit("bookmark=" + bookmark.DisplayName) @@ -416,7 +442,8 @@ func listChannelBookmarksForChannel(c *Context, w http.ResponseWriter, r *http.R return } - if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) { + hasPermission, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) + if !hasPermission { c.SetPermissionError(model.PermissionReadChannelContent) return } @@ -427,6 +454,13 @@ func listChannelBookmarksForChannel(c *Context, w http.ResponseWriter, r *http.R return } + auditRec := c.MakeAuditRecord(model.AuditEventListChannelBookmarksForChannel, model.AuditStatusSuccess) + defer c.LogAuditRec(auditRec) + model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId) + if !isMember { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } + if err := json.NewEncoder(w).Encode(bookmarks); err != nil { c.Logger.Warn("Error while writing response", mlog.Err(err)) } diff --git a/server/channels/api4/channel_test.go b/server/channels/api4/channel_test.go index fe3cca01f5f..6809cc9166b 100644 --- a/server/channels/api4/channel_test.go +++ b/server/channels/api4/channel_test.go @@ -8,7 +8,6 @@ import ( "encoding/json" "fmt" "net/http" - "sort" "strings" "testing" "time" @@ -256,7 +255,7 @@ func TestCreateChannel(t *testing.T) { require.NoError(t, err) // Verify that the guest user can access the private channel they were added to - _, _, err = guestClient.GetChannel(context.Background(), private.Id, "") + _, _, err = guestClient.GetChannel(context.Background(), private.Id) require.NoError(t, err) // Verify that the guest user cannot add members to the private channel @@ -269,7 +268,7 @@ func TestCreateChannel(t *testing.T) { require.NoError(t, err) // Verify that the guest user can access the public channel they were added to - _, _, err = guestClient.GetChannel(context.Background(), public.Id, "") + _, _, err = guestClient.GetChannel(context.Background(), public.Id) require.NoError(t, err) // Verify that the guest user cannot add members to the public channel @@ -1625,50 +1624,50 @@ func TestGetChannel(t *testing.T) { th := Setup(t).InitBasic(t) client := th.Client - channel, _, err := client.GetChannel(context.Background(), th.BasicChannel.Id, "") + channel, _, err := client.GetChannel(context.Background(), th.BasicChannel.Id) require.NoError(t, err) require.Equal(t, th.BasicChannel.Id, channel.Id, "ids did not match") _, err = client.RemoveUserFromChannel(context.Background(), th.BasicChannel.Id, th.BasicUser.Id) require.NoError(t, err) - _, _, err = client.GetChannel(context.Background(), th.BasicChannel.Id, "") + _, _, err = client.GetChannel(context.Background(), th.BasicChannel.Id) require.NoError(t, err) - channel, _, err = client.GetChannel(context.Background(), th.BasicPrivateChannel.Id, "") + channel, _, err = client.GetChannel(context.Background(), th.BasicPrivateChannel.Id) require.NoError(t, err) require.Equal(t, th.BasicPrivateChannel.Id, channel.Id, "ids did not match") _, err = client.RemoveUserFromChannel(context.Background(), th.BasicPrivateChannel.Id, th.BasicUser.Id) require.NoError(t, err) - _, resp, err := client.GetChannel(context.Background(), th.BasicPrivateChannel.Id, "") + _, resp, err := client.GetChannel(context.Background(), th.BasicPrivateChannel.Id) require.Error(t, err) CheckForbiddenStatus(t, resp) - _, resp, err = client.GetChannel(context.Background(), model.NewId(), "") + _, resp, err = client.GetChannel(context.Background(), model.NewId()) require.Error(t, err) CheckNotFoundStatus(t, resp) _, err = client.Logout(context.Background()) require.NoError(t, err) - _, resp, err = client.GetChannel(context.Background(), th.BasicChannel.Id, "") + _, resp, err = client.GetChannel(context.Background(), th.BasicChannel.Id) require.Error(t, err) CheckUnauthorizedStatus(t, resp) user := th.CreateUser(t) _, _, err = client.Login(context.Background(), user.Email, user.Password) require.NoError(t, err) - _, resp, err = client.GetChannel(context.Background(), th.BasicChannel.Id, "") + _, resp, err = client.GetChannel(context.Background(), th.BasicChannel.Id) require.Error(t, err) CheckForbiddenStatus(t, resp) th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { - _, _, err = client.GetChannel(context.Background(), th.BasicChannel.Id, "") + _, _, err = client.GetChannel(context.Background(), th.BasicChannel.Id) require.NoError(t, err) - _, _, err = client.GetChannel(context.Background(), th.BasicPrivateChannel.Id, "") + _, _, err = client.GetChannel(context.Background(), th.BasicPrivateChannel.Id) require.NoError(t, err) - _, resp, err = client.GetChannel(context.Background(), th.BasicUser.Id, "") + _, resp, err = client.GetChannel(context.Background(), th.BasicUser.Id) require.Error(t, err) CheckNotFoundStatus(t, resp) }) @@ -1930,21 +1929,22 @@ func TestGetPublicChannelsByIdsForTeam(t *testing.T) { t.Run("should return 2 channels", func(t *testing.T) { input := []string{th.BasicChannel.Id} - output := []string{th.BasicChannel.DisplayName} + expectedDisplayNames := []string{th.BasicChannel.DisplayName} input = append(input, GenerateTestID()) input = append(input, th.BasicChannel2.Id) input = append(input, th.BasicPrivateChannel.Id) - output = append(output, th.BasicChannel2.DisplayName) - sort.Strings(output) + expectedDisplayNames = append(expectedDisplayNames, th.BasicChannel2.DisplayName) channels, _, err := client.GetPublicChannelsByIdsForTeam(context.Background(), teamId, input) require.NoError(t, err) require.Len(t, channels, 2, "should return 2 channels") + actualDisplayNames := make([]string, len(channels)) for i, c := range channels { - require.Equal(t, output[i], c.DisplayName, "missing channel") + actualDisplayNames[i] = c.DisplayName } + require.ElementsMatch(t, expectedDisplayNames, actualDisplayNames, "missing channel") }) t.Run("forbidden for invalid team", func(t *testing.T) { @@ -3644,7 +3644,7 @@ func TestViewChannel(t *testing.T) { member, _, err := client.GetChannelMember(context.Background(), th.BasicChannel.Id, th.BasicUser.Id, "") require.NoError(t, err) - channel, _, err = client.GetChannel(context.Background(), th.BasicChannel.Id, "") + channel, _, err = client.GetChannel(context.Background(), th.BasicChannel.Id) require.NoError(t, err) require.Equal(t, channel.TotalMsgCount, member.MsgCount, "should match message counts") require.Equal(t, int64(0), member.MentionCount, "should have no mentions") @@ -3679,9 +3679,9 @@ func TestReadMultipleChannels(t *testing.T) { user := th.BasicUser t.Run("Should successfully mark public channels as read for self", func(t *testing.T) { - channel, _, err := client.GetChannel(context.Background(), th.BasicChannel.Id, "") + channel, _, err := client.GetChannel(context.Background(), th.BasicChannel.Id) require.NoError(t, err) - channel2, _, err := client.GetChannel(context.Background(), th.BasicChannel2.Id, "") + channel2, _, err := client.GetChannel(context.Background(), th.BasicChannel2.Id) require.NoError(t, err) channelResponse, _, err := client.ReadMultipleChannels(context.Background(), user.Id, []string{channel.Id, channel2.Id}) @@ -3692,7 +3692,7 @@ func TestReadMultipleChannels(t *testing.T) { }) t.Run("Should successfully mark private channels as read for self", func(t *testing.T) { - channel, _, err := client.GetChannel(context.Background(), th.BasicPrivateChannel.Id, "") + channel, _, err := client.GetChannel(context.Background(), th.BasicPrivateChannel.Id) require.NoError(t, err) // private channel without membership should be ignored @@ -3704,7 +3704,7 @@ func TestReadMultipleChannels(t *testing.T) { }) t.Run("Should fail marking public/private channels for other user", func(t *testing.T) { - channel, _, err := client.GetChannel(context.Background(), th.BasicChannel.Id, "") + channel, _, err := client.GetChannel(context.Background(), th.BasicChannel.Id) require.NoError(t, err) _, _, err = client.ReadMultipleChannels(context.Background(), th.BasicUser2.Id, []string{channel.Id}) @@ -3713,9 +3713,9 @@ func TestReadMultipleChannels(t *testing.T) { t.Run("Admin should succeed in marking public/private channels for other user", func(t *testing.T) { adminClient := th.SystemAdminClient - channel, _, err := adminClient.GetChannel(context.Background(), th.BasicChannel.Id, "") + channel, _, err := adminClient.GetChannel(context.Background(), th.BasicChannel.Id) require.NoError(t, err) - privateChannel, _, err := adminClient.GetChannel(context.Background(), th.BasicPrivateChannel.Id, "") + privateChannel, _, err := adminClient.GetChannel(context.Background(), th.BasicPrivateChannel.Id) require.NoError(t, err) channelResponse, _, err := adminClient.ReadMultipleChannels(context.Background(), th.BasicUser2.Id, []string{channel.Id, privateChannel.Id}) @@ -3729,9 +3729,9 @@ func TestReadMultipleChannels(t *testing.T) { th.LoginSystemManager(t) sysMgrClient := th.SystemManagerClient - channel, _, err := sysMgrClient.GetChannel(context.Background(), th.BasicChannel.Id, "") + channel, _, err := sysMgrClient.GetChannel(context.Background(), th.BasicChannel.Id) require.NoError(t, err) - privateChannel, _, err := sysMgrClient.GetChannel(context.Background(), th.BasicPrivateChannel.Id, "") + privateChannel, _, err := sysMgrClient.GetChannel(context.Background(), th.BasicPrivateChannel.Id) require.NoError(t, err) _, _, err = sysMgrClient.ReadMultipleChannels(context.Background(), th.BasicUser2.Id, []string{channel.Id, privateChannel.Id}) diff --git a/server/channels/api4/command.go b/server/channels/api4/command.go index 81f18d2b666..e50830a1e4e 100644 --- a/server/channels/api4/command.go +++ b/server/channels/api4/command.go @@ -371,7 +371,7 @@ func executeCommand(c *Context, w http.ResponseWriter, r *http.Request) { model.AddEventParameterAuditableToAuditRec(auditRec, "command_args", &commandArgs) // Checks that user is a member of the specified channel, and that they have permission to create a post in it. - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), commandArgs.ChannelId, model.PermissionCreatePost) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), commandArgs.ChannelId, model.PermissionCreatePost); !ok { c.SetPermissionError(model.PermissionCreatePost) return } diff --git a/server/channels/api4/config.go b/server/channels/api4/config.go index 701a2cd0654..046c49e4d27 100644 --- a/server/channels/api4/config.go +++ b/server/channels/api4/config.go @@ -159,6 +159,9 @@ func updateConfig(c *Context, w http.ResponseWriter, r *http.Request) { // modifications to the slice. cfg.PluginSettings.SignaturePublicKeyFiles = appCfg.PluginSettings.SignaturePublicKeyFiles + // Do not allow import directory to be changed through the API + *cfg.ImportSettings.Directory = *appCfg.ImportSettings.Directory + // Do not allow marketplace URL to be toggled through the API if EnableUploads are disabled. if cfg.PluginSettings.EnableUploads != nil && !*appCfg.PluginSettings.EnableUploads { *cfg.PluginSettings.MarketplaceURL = *appCfg.PluginSettings.MarketplaceURL @@ -305,6 +308,12 @@ func patchConfig(c *Context, w http.ResponseWriter, r *http.Request) { return } + // Do not allow import directory to be changed through the API + if cfg.ImportSettings.Directory != nil && *cfg.ImportSettings.Directory != *appCfg.ImportSettings.Directory { + c.Err = model.NewAppError("patchConfig", "api.config.update_config.not_allowed_security.app_error", map[string]any{"Name": "ImportSettings.Directory"}, "", http.StatusForbidden) + return + } + // Do not allow marketplace URL to be toggled if plugin uploads are disabled. if cfg.PluginSettings.MarketplaceURL != nil && cfg.PluginSettings.EnableUploads != nil { // Breaking it down to 2 conditions to make it simple. diff --git a/server/channels/api4/config_test.go b/server/channels/api4/config_test.go index 86db88ca3ab..a73ef52d774 100644 --- a/server/channels/api4/config_test.go +++ b/server/channels/api4/config_test.go @@ -305,6 +305,43 @@ func TestUpdateConfig(t *testing.T) { CheckForbiddenStatus(t, resp) }) + t.Run("Should not be able to modify ImportSettings.Directory", func(t *testing.T) { + t.Run("sysadmin", func(t *testing.T) { + oldDirectory := *th.App.Config().ImportSettings.Directory + cfg2 := th.App.Config().Clone() + *cfg2.ImportSettings.Directory = "./new-import-dir" + + cfg2, _, err = th.SystemAdminClient.UpdateConfig(context.Background(), cfg2) + require.NoError(t, err) + assert.Equal(t, oldDirectory, *cfg2.ImportSettings.Directory) + assert.Equal(t, oldDirectory, *th.App.Config().ImportSettings.Directory) + + cfg2.ImportSettings.Directory = nil + cfg2, _, err = th.SystemAdminClient.UpdateConfig(context.Background(), cfg2) + require.NoError(t, err) + assert.Equal(t, oldDirectory, *cfg2.ImportSettings.Directory) + assert.Equal(t, oldDirectory, *th.App.Config().ImportSettings.Directory) + }) + + t.Run("local mode", func(t *testing.T) { + oldDirectory := *th.App.Config().ImportSettings.Directory + cfg2 := th.App.Config().Clone() + newDirectory := "./new-import-dir" + *cfg2.ImportSettings.Directory = newDirectory + + cfg2, _, err = th.LocalClient.UpdateConfig(context.Background(), cfg2) + require.NoError(t, err) + assert.Equal(t, newDirectory, *cfg2.ImportSettings.Directory) + assert.Equal(t, newDirectory, *th.App.Config().ImportSettings.Directory) + + cfg2.ImportSettings.Directory = nil + cfg2, _, err = th.LocalClient.UpdateConfig(context.Background(), cfg2) + require.NoError(t, err) + assert.Equal(t, oldDirectory, *cfg2.ImportSettings.Directory) + assert.Equal(t, oldDirectory, *th.App.Config().ImportSettings.Directory) + }) + }) + t.Run("System Admin should not be able to clear Site URL", func(t *testing.T) { siteURL := cfg.ServiceSettings.SiteURL defer th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SiteURL = siteURL }) @@ -819,6 +856,30 @@ func TestPatchConfig(t *testing.T) { CheckForbiddenStatus(t, resp) } }) + + t.Run("not allowing to change import directory via api, unless local mode", func(t *testing.T) { + oldDirectory := *th.App.Config().ImportSettings.Directory + config := model.Config{ImportSettings: model.ImportSettings{ + Directory: model.NewPointer("./new-import-dir"), + }} + + updatedConfig, resp, err := client.PatchConfig(context.Background(), &config) + if client == th.LocalClient { + require.NoError(t, err) + CheckOKStatus(t, resp) + assert.Equal(t, "./new-import-dir", *updatedConfig.ImportSettings.Directory) + } else { + require.Error(t, err) + CheckForbiddenStatus(t, resp) + } + + // Reset for local mode + if client == th.LocalClient { + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ImportSettings.Directory = oldDirectory + }) + } + }) }) t.Run("Should not be able to modify PluginSettings.MarketplaceURL if EnableUploads is disabled", func(t *testing.T) { diff --git a/server/channels/api4/content_flagging.go b/server/channels/api4/content_flagging.go index 5540b6a82dd..47598fef666 100644 --- a/server/channels/api4/content_flagging.go +++ b/server/channels/api4/content_flagging.go @@ -169,7 +169,7 @@ func flagPost(c *Context, w http.ResponseWriter, r *http.Request) { model.AddEventParameterToAuditRec(auditRec, "postId", postId) model.AddEventParameterToAuditRec(auditRec, "userId", userId) - post, appErr := c.App.GetPostIfAuthorized(c.AppContext, postId, c.AppContext.Session(), false) + post, appErr, _ := c.App.GetPostIfAuthorized(c.AppContext, postId, c.AppContext.Session(), false) if appErr != nil { c.Err = appErr return @@ -341,7 +341,7 @@ func getFlaggedPost(c *Context, w http.ResponseWriter, r *http.Request) { } post = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, post, &model.PreparePostForClientOpts{IncludePriority: true, RetainContent: true, IncludeDeleted: true}) - post, err := c.App.SanitizePostMetadataForUser(c.AppContext, post, c.AppContext.Session().UserId) + post, isMemberForPreviews, err := c.App.SanitizePostMetadataForUser(c.AppContext, post, c.AppContext.Session().UserId) if err != nil { c.Err = err return @@ -352,6 +352,14 @@ func getFlaggedPost(c *Context, w http.ResponseWriter, r *http.Request) { return } + if !isMemberForPreviews { + previewPost := post.GetPreviewPost() + if previewPost != nil { + model.AddEventParameterToAuditRec(auditRec, "preview_post_id", previewPost.Post.Id) + } + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } + auditRec.Success() } diff --git a/server/channels/api4/drafts.go b/server/channels/api4/drafts.go index a1a8d02f4e2..499e68bfe88 100644 --- a/server/channels/api4/drafts.go +++ b/server/channels/api4/drafts.go @@ -38,7 +38,7 @@ func upsertDraft(c *Context, w http.ResponseWriter, r *http.Request) { hasPermission := false - if c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), draft.ChannelId, model.PermissionCreatePost) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), draft.ChannelId, model.PermissionCreatePost); ok { hasPermission = true } else if channel, err := c.App.GetChannel(c.AppContext, draft.ChannelId); err == nil { // Temporary permission check method until advanced permissions, please do not copy diff --git a/server/channels/api4/file.go b/server/channels/api4/file.go index ed4fdd4909f..633a4112481 100644 --- a/server/channels/api4/file.go +++ b/server/channels/api4/file.go @@ -142,7 +142,7 @@ func uploadFileSimple(c *Context, r *http.Request, timestamp time.Time) *model.F defer c.LogAuditRec(auditRec) model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId) - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionUploadFile) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionUploadFile); !ok { c.SetPermissionError(model.PermissionUploadFile) return nil } @@ -316,7 +316,7 @@ NextPart: if c.Err != nil { return nil } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionUploadFile) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionUploadFile); !ok { c.SetPermissionError(model.PermissionUploadFile) return nil } @@ -429,7 +429,7 @@ func uploadFileMultipartLegacy(c *Context, mr *multipart.Reader, if c.Err != nil { return nil } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channelId, model.PermissionUploadFile) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channelId, model.PermissionUploadFile); !ok { c.SetPermissionError(model.PermissionUploadFile) return nil } @@ -570,8 +570,8 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) { model.AddEventParameterAuditableToAuditRec(auditRec, "file", fileInfo) + perm, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) if !isContentReviewer { - perm := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) if fileInfo.CreatorId == model.BookmarkFileOwner { if !perm { c.SetPermissionError(model.PermissionReadChannelContent) @@ -594,6 +594,10 @@ func getFile(c *Context, w http.ResponseWriter, r *http.Request) { auditRec.Success() web.WriteFileResponse(fileInfo.Name, fileInfo.MimeType, fileInfo.Size, time.Unix(0, fileInfo.UpdateAt*int64(1000*1000)), *c.App.Config().ServiceSettings.WebserverMode, fileReader, forceDownload, w, r) + + if !isMember { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } } func getFileThumbnail(c *Context, w http.ResponseWriter, r *http.Request) { @@ -615,7 +619,7 @@ func getFileThumbnail(c *Context, w http.ResponseWriter, r *http.Request) { c.Err = err return } - perm := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) + perm, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) if info.CreatorId == model.BookmarkFileOwner { if !perm { c.SetPermissionError(model.PermissionReadChannelContent) @@ -640,6 +644,13 @@ func getFileThumbnail(c *Context, w http.ResponseWriter, r *http.Request) { defer fileReader.Close() web.WriteFileResponse(info.Name, ThumbnailImageType, 0, time.Unix(0, info.UpdateAt*int64(1000*1000)), *c.App.Config().ServiceSettings.WebserverMode, fileReader, forceDownload, w, r) + + auditRec := c.MakeAuditRecord(model.AuditEventGetFileThumbnail, model.AuditStatusSuccess) + defer c.LogAuditRec(auditRec) + model.AddEventParameterToAuditRec(auditRec, "file_id", c.Params.FileId) + if !isMember { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } } func getFileLink(c *Context, w http.ResponseWriter, r *http.Request) { @@ -669,7 +680,7 @@ func getFileLink(c *Context, w http.ResponseWriter, r *http.Request) { c.Err = err return } - perm := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) + perm, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) if info.CreatorId == model.BookmarkFileOwner { if !perm { c.SetPermissionError(model.PermissionReadChannelContent) @@ -685,6 +696,10 @@ func getFileLink(c *Context, w http.ResponseWriter, r *http.Request) { return } + if !isMember { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } + resp := make(map[string]string) link := c.App.GeneratePublicLink(c.GetSiteURLHeader(), info) resp["link"] = link @@ -715,7 +730,7 @@ func getFilePreview(c *Context, w http.ResponseWriter, r *http.Request) { c.Err = err return } - perm := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) + perm, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) if info.CreatorId == model.BookmarkFileOwner { if !perm { c.SetPermissionError(model.PermissionReadChannelContent) @@ -740,6 +755,13 @@ func getFilePreview(c *Context, w http.ResponseWriter, r *http.Request) { defer fileReader.Close() web.WriteFileResponse(info.Name, PreviewImageType, 0, time.Unix(0, info.UpdateAt*int64(1000*1000)), *c.App.Config().ServiceSettings.WebserverMode, fileReader, forceDownload, w, r) + + auditRec := c.MakeAuditRecord(model.AuditEventGetFilePreview, model.AuditStatusSuccess) + defer c.LogAuditRec(auditRec) + model.AddEventParameterToAuditRec(auditRec, "file_id", c.Params.FileId) + if !isMember { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } } func getFileInfo(c *Context, w http.ResponseWriter, r *http.Request) { @@ -760,7 +782,7 @@ func getFileInfo(c *Context, w http.ResponseWriter, r *http.Request) { c.Err = err return } - perm := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) + perm, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) if info.CreatorId == model.BookmarkFileOwner { if !perm { c.SetPermissionError(model.PermissionReadChannelContent) @@ -775,6 +797,14 @@ func getFileInfo(c *Context, w http.ResponseWriter, r *http.Request) { if err := json.NewEncoder(w).Encode(info); err != nil { c.Logger.Warn("Error while writing response", mlog.Err(err)) } + + auditRec := c.MakeAuditRecord(model.AuditEventGetFileInfo, model.AuditStatusSuccess) + defer c.LogAuditRec(auditRec) + model.AddEventParameterToAuditRec(auditRec, "file_id", c.Params.FileId) + + if !isMember { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } } func getPublicFile(c *Context, w http.ResponseWriter, r *http.Request) { @@ -879,7 +909,7 @@ func searchFiles(c *Context, w http.ResponseWriter, r *http.Request, teamID stri startTime := time.Now() - results, err := c.App.SearchFilesInTeamForUser(c.AppContext, terms, c.AppContext.Session().UserId, teamID, isOrSearch, includeDeletedChannels, timeZoneOffset, page, perPage) + results, allFilesHaveMembership, err := c.App.SearchFilesInTeamForUser(c.AppContext, terms, c.AppContext.Session().UserId, teamID, isOrSearch, includeDeletedChannels, timeZoneOffset, page, perPage) elapsedTime := float64(time.Since(startTime)) / float64(time.Second) metrics := c.App.Metrics() @@ -897,6 +927,16 @@ func searchFiles(c *Context, w http.ResponseWriter, r *http.Request, teamID stri if err := json.NewEncoder(w).Encode(results); err != nil { c.Logger.Warn("Error while writing response", mlog.Err(err)) } + + auditRec := c.MakeAuditRecord(model.AuditEventSearchFiles, model.AuditStatusSuccess) + defer c.LogAuditRec(auditRec) + model.AddEventParameterAuditableToAuditRec(auditRec, "search_params", params) + + if !allFilesHaveMembership { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } + + auditRec.Success() } func setInaccessibleFileHeader(w http.ResponseWriter, appErr *model.AppError) { diff --git a/server/channels/api4/group.go b/server/channels/api4/group.go index 5f679371e75..d6eb107990f 100644 --- a/server/channels/api4/group.go +++ b/server/channels/api4/group.go @@ -697,7 +697,7 @@ func verifyLinkUnlinkPermission(c *Context, syncableType model.GroupSyncableType permission = model.PermissionManagePublicChannelMembers } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), syncableID, permission) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), syncableID, permission); !ok { return model.MakePermissionError(c.AppContext.Session(), []*model.Permission{permission}) } } @@ -972,7 +972,7 @@ func getGroupsByChannelCommon(c *Context, r *http.Request) ([]byte, *model.AppEr } else { permission = model.PermissionReadPublicChannelGroups } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, permission) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, permission); !ok { return nil, model.MakePermissionError(c.AppContext.Session(), []*model.Permission{permission}) } @@ -1138,7 +1138,7 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { } else { permission = model.PermissionManagePublicChannelMembers } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), NotAssociatedToChannelID, permission) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), NotAssociatedToChannelID, permission); !ok { c.SetPermissionError(permission) return } @@ -1157,7 +1157,7 @@ func getGroups(c *Context, w http.ResponseWriter, r *http.Request) { } else { permission = model.PermissionManagePublicChannelMembers } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), ChannelIDForMemberCount, permission) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), ChannelIDForMemberCount, permission); !ok { c.SetPermissionError(permission) return } diff --git a/server/channels/api4/group_test.go b/server/channels/api4/group_test.go index 18d3539aab7..e36bb82eab5 100644 --- a/server/channels/api4/group_test.go +++ b/server/channels/api4/group_test.go @@ -2052,16 +2052,8 @@ func TestGetGroups(t *testing.T) { t.Run("not associated to channel", func(t *testing.T) { opts := baseOpts - resp, err := th.SystemAdminClient.UpdateChannelRoles(context.Background(), th.BasicChannel.Id, th.BasicUser.Id, "") - require.NoError(t, err) - CheckOKStatus(t, resp) - opts.NotAssociatedToChannel = th.BasicChannel.Id - resp, err = th.SystemAdminClient.UpdateChannelRoles(context.Background(), th.BasicChannel.Id, th.BasicUser.Id, "channel_user channel_admin") - require.NoError(t, err) - CheckOKStatus(t, resp) - groups, resp, err := th.SystemAdminClient.GetGroups(context.Background(), opts) require.NoError(t, err) CheckOKStatus(t, resp) @@ -2070,16 +2062,8 @@ func TestGetGroups(t *testing.T) { t.Run("not associated to team", func(t *testing.T) { opts := baseOpts - resp, err := th.SystemAdminClient.UpdateTeamMemberRoles(context.Background(), th.BasicTeam.Id, th.BasicUser.Id, "") - require.NoError(t, err) - CheckOKStatus(t, resp) - opts.NotAssociatedToTeam = th.BasicTeam.Id - resp, err = th.SystemAdminClient.UpdateTeamMemberRoles(context.Background(), th.BasicTeam.Id, th.BasicUser.Id, "team_user team_admin") - require.NoError(t, err) - CheckOKStatus(t, resp) - groups, resp, err := th.SystemAdminClient.GetGroups(context.Background(), opts) require.NoError(t, err) CheckOKStatus(t, resp) diff --git a/server/channels/api4/integration_action.go b/server/channels/api4/integration_action.go index 53dfb6cffde..c0ab52462a1 100644 --- a/server/channels/api4/integration_action.go +++ b/server/channels/api4/integration_action.go @@ -67,12 +67,12 @@ func doPostAction(c *Context, w http.ResponseWriter, r *http.Request) { c.Err = err return } - if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) { + if ok, _ := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel); !ok { c.SetPermissionError(model.PermissionReadChannelContent) return } } else { - if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.PostId, model.PermissionReadChannelContent) { + if ok, _ := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), c.Params.PostId); !ok { c.SetPermissionError(model.PermissionReadChannelContent) return } @@ -136,7 +136,7 @@ func submitDialog(c *Context, w http.ResponseWriter, r *http.Request) { c.Err = err return } - if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) { + if ok, _ := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel); !ok { c.SetPermissionError(model.PermissionReadChannelContent) return } @@ -189,7 +189,7 @@ func lookupDialog(c *Context, w http.ResponseWriter, r *http.Request) { c.Err = err return } - if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) { + if ok, _ := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel); !ok { c.SetPermissionError(model.PermissionReadChannelContent) return } diff --git a/server/channels/api4/post.go b/server/channels/api4/post.go index d0a59efce69..dcc8129aeee 100644 --- a/server/channels/api4/post.go +++ b/server/channels/api4/post.go @@ -68,6 +68,13 @@ func createPostChecks(where string, c *Context, post *model.Post) { return } + if len(post.FileIds) > 0 { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), post.ChannelId, model.PermissionUploadFile); !ok { + c.SetPermissionError(model.PermissionUploadFile) + return + } + } + postHardenedModeCheckWithContext(where, c, post.GetProps()) if c.Err != nil { return @@ -110,7 +117,7 @@ func createPost(c *Context, w http.ResponseWriter, r *http.Request) { } } - rp, err := c.App.CreatePostAsUser(c.AppContext, c.App.PostWithProxyRemovedFromImageURLs(&post), c.AppContext.Session().Id, setOnlineBool) + rp, isMemberForPreviews, err := c.App.CreatePostAsUser(c.AppContext, c.App.PostWithProxyRemovedFromImageURLs(&post), c.AppContext.Session().Id, setOnlineBool) if err != nil { c.Err = err return @@ -119,6 +126,14 @@ func createPost(c *Context, w http.ResponseWriter, r *http.Request) { auditRec.AddEventResultState(rp) auditRec.AddEventObjectType("post") + if !isMemberForPreviews { + previewPost := rp.GetPreviewPost() + if previewPost != nil { + model.AddEventParameterToAuditRec(auditRec, "preview_post_id", previewPost.Post.Id) + } + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } + if setOnlineBool { c.App.SetStatusOnline(c.AppContext.Session().UserId, false) } @@ -182,12 +197,13 @@ func createEphemeralPost(c *Context, w http.ResponseWriter, r *http.Request) { return } - rp := c.App.SendEphemeralPost(c.AppContext, ephRequest.UserID, c.App.PostWithProxyRemovedFromImageURLs(ephRequest.Post)) + // We prepare again the post here, so we can ignore the isMemberForPreviews return value from SendEphemeralPost + rp, _ := c.App.SendEphemeralPost(c.AppContext, ephRequest.UserID, c.App.PostWithProxyRemovedFromImageURLs(ephRequest.Post)) w.WriteHeader(http.StatusCreated) rp = model.AddPostActionCookies(rp, c.App.PostActionCookieSecret()) rp = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, rp, &model.PreparePostForClientOpts{IsNewPost: true, IncludePriority: true}) - rp, err := c.App.SanitizePostMetadataForUser(c.AppContext, rp, c.AppContext.Session().UserId) + rp, isMemberForPreviews, err := c.App.SanitizePostMetadataForUser(c.AppContext, rp, c.AppContext.Session().UserId) if err != nil { c.Err = err return @@ -195,6 +211,19 @@ func createEphemeralPost(c *Context, w http.ResponseWriter, r *http.Request) { if err := rp.EncodeJSON(w); err != nil { c.Logger.Warn("Error while writing response", mlog.Err(err)) } + + auditRec := c.MakeAuditRecord(model.AuditEventCreateEphemeralPost, model.AuditStatusSuccess) + defer c.LogAuditRec(auditRec) + model.AddEventParameterToAuditRec(auditRec, "post_id", rp.Id) + + if !isMemberForPreviews { + previewPost := rp.GetPreviewPost() + if previewPost != nil { + model.AddEventParameterToAuditRec(auditRec, "preview_post_id", previewPost.Post.Id) + } + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } + auditRec.Success() } func getPostsForChannel(c *Context, w http.ResponseWriter, r *http.Request) { @@ -243,7 +272,8 @@ func getPostsForChannel(c *Context, w http.ResponseWriter, r *http.Request) { c.Err = err return } - if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) { + hasPermission, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) + if !hasPermission { c.SetPermissionError(model.PermissionReadChannelContent) return } @@ -294,7 +324,7 @@ func getPostsForChannel(c *Context, w http.ResponseWriter, r *http.Request) { // to ensure they only reference posts that are actually in the response c.App.AddCursorIdsForPostList(clientPostList, afterPost, beforePost, since, page, perPage, collapsedThreads) - clientPostList, err = c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId) + clientPostList, isMemberForAllPreviews, err := c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId) if err != nil { c.Err = err return @@ -303,6 +333,16 @@ func getPostsForChannel(c *Context, w http.ResponseWriter, r *http.Request) { if err := clientPostList.EncodeJSON(w); err != nil { c.Logger.Warn("Error while writing response", mlog.Err(err)) } + + auditRec := c.MakeAuditRecord(model.AuditEventGetPostsForChannel, model.AuditStatusSuccess) + defer c.LogAuditRec(auditRec) + model.AddEventParameterToAuditRec(auditRec, "channel_id", channelId) + if !isMember || !isMemberForAllPreviews { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + if !isMemberForAllPreviews { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access_on_previews", true) + } + } } func getPostsForChannelAroundLastUnread(c *Context, w http.ResponseWriter, r *http.Request) { @@ -323,7 +363,8 @@ func getPostsForChannelAroundLastUnread(c *Context, w http.ResponseWriter, r *ht c.Err = err return } - if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) { + hasPermission, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) + if !hasPermission { c.SetPermissionError(model.PermissionReadChannelContent) return } @@ -364,7 +405,7 @@ func getPostsForChannelAroundLastUnread(c *Context, w http.ResponseWriter, r *ht // to ensure they only reference posts that are actually in the response clientPostList.NextPostId = c.App.GetNextPostIdFromPostList(clientPostList, collapsedThreads) clientPostList.PrevPostId = c.App.GetPrevPostIdFromPostList(clientPostList, collapsedThreads) - clientPostList, err = c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId) + clientPostList, isMemberForAllPreviews, err := c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId) if err != nil { c.Err = err return @@ -376,6 +417,17 @@ func getPostsForChannelAroundLastUnread(c *Context, w http.ResponseWriter, r *ht if err := clientPostList.EncodeJSON(w); err != nil { c.Logger.Warn("Error while writing response", mlog.Err(err)) } + + auditRec := c.MakeAuditRecord(model.AuditEventGetPostsForChannelAroundLastUnread, model.AuditStatusSuccess) + defer c.LogAuditRec(auditRec) + model.AddEventParameterToAuditRec(auditRec, "channel_id", channelId) + + if !isMember || !isMemberForAllPreviews { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + if !isMemberForAllPreviews { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access_on_previews", true) + } + } } func getFlaggedPostsForUser(c *Context, w http.ResponseWriter, r *http.Request) { @@ -423,6 +475,7 @@ func getFlaggedPostsForUser(c *Context, w http.ResponseWriter, r *http.Request) pl := model.NewPostList() channelReadPermission := make(map[string]bool) + isMemberForAllPosts := true for _, post := range posts.Posts { allowed, ok := channelReadPermission[post.ChannelId] @@ -434,8 +487,11 @@ func getFlaggedPostsForUser(c *Context, w http.ResponseWriter, r *http.Request) if !ok { continue } - if c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) { + + hasPermission, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) + if hasPermission { allowed = true + isMemberForAllPosts = isMemberForAllPosts && isMember } channelReadPermission[post.ChannelId] = allowed @@ -451,11 +507,23 @@ func getFlaggedPostsForUser(c *Context, w http.ResponseWriter, r *http.Request) pl.SortByCreateAt() clientPostList := c.App.PreparePostListForClient(c.AppContext, pl) - clientPostList, err = c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId) + clientPostList, isMemberForAllPreviews, err := c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId) if err != nil { c.Err = err return } + + auditRec := c.MakeAuditRecord(model.AuditEventGetFlaggedPosts, model.AuditStatusSuccess) + defer c.LogAuditRec(auditRec) + model.AddEventParameterToAuditRec(auditRec, "channel_id", channelId) + + if !isMemberForAllPosts || !isMemberForAllPreviews { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + if !isMemberForAllPreviews { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access_on_previews", true) + } + } + if err := clientPostList.EncodeJSON(w); err != nil { c.Logger.Warn("Error while writing response", mlog.Err(err)) } @@ -474,7 +542,7 @@ func getPost(c *Context, w http.ResponseWriter, r *http.Request) { return } - post, err := c.App.GetPostIfAuthorized(c.AppContext, c.Params.PostId, c.AppContext.Session(), includeDeleted) + post, err, isMember := c.App.GetPostIfAuthorized(c.AppContext, c.Params.PostId, c.AppContext.Session(), includeDeleted) if err != nil { c.Err = err @@ -487,7 +555,7 @@ func getPost(c *Context, w http.ResponseWriter, r *http.Request) { } post = c.App.PreparePostForClientWithEmbedsAndImages(c.AppContext, post, &model.PreparePostForClientOpts{IncludePriority: true}) - post, err = c.App.SanitizePostMetadataForUser(c.AppContext, post, c.AppContext.Session().UserId) + post, previewIsMember, err := c.App.SanitizePostMetadataForUser(c.AppContext, post, c.AppContext.Session().UserId) if err != nil { c.Err = err return @@ -501,6 +569,20 @@ func getPost(c *Context, w http.ResponseWriter, r *http.Request) { if err := post.EncodeJSON(w); err != nil { c.Logger.Warn("Error while writing response", mlog.Err(err)) } + + auditRec := c.MakeAuditRecord(model.AuditEventGetPost, model.AuditStatusSuccess) + defer c.LogAuditRec(auditRec) + model.AddEventParameterToAuditRec(auditRec, "post_id", c.Params.PostId) + + if !isMember || !previewIsMember { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + if !previewIsMember { + previewPost := post.GetPreviewPost() + if previewPost != nil { + model.AddEventParameterToAuditRec(auditRec, "preview_post_id", previewPost.Post.Id) + } + } + } } // getPostsByIds also sets a header to indicate, if posts were truncated as per the cloud plan's limit. @@ -540,16 +622,20 @@ func getPostsByIds(c *Context, w http.ResponseWriter, r *http.Request) { } var posts = []*model.Post{} + isMemberForAllPosts := true for _, post := range postsList { channel, ok := channelMap[post.ChannelId] if !ok { continue } - if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) { + hasPermission, isMemberForCurrentPost := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) + if !hasPermission { continue } + isMemberForAllPosts = isMemberForAllPosts && isMemberForCurrentPost + post = c.App.PreparePostForClient(c.AppContext, post, &model.PreparePostForClientOpts{IncludePriority: true}) post.StripActionIntegrations() posts = append(posts, post) @@ -560,6 +646,14 @@ func getPostsByIds(c *Context, w http.ResponseWriter, r *http.Request) { if err := json.NewEncoder(w).Encode(posts); err != nil { c.Logger.Warn("Error while writing response", mlog.Err(err)) } + + auditRec := c.MakeAuditRecord(model.AuditEventGetPostsByIds, model.AuditStatusSuccess) + defer c.LogAuditRec(auditRec) + model.AddEventParameterToAuditRec(auditRec, "post_ids", postIDs) + + if !isMemberForAllPosts { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } } func getEditHistoryForPost(c *Context, w http.ResponseWriter, r *http.Request) { @@ -574,7 +668,8 @@ func getEditHistoryForPost(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionEditPost) { + ok, isMember := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionEditPost) + if !ok { c.SetPermissionError(model.PermissionEditPost) return } @@ -590,6 +685,14 @@ func getEditHistoryForPost(c *Context, w http.ResponseWriter, r *http.Request) { return } + auditRec := c.MakeAuditRecord(model.AuditEventGetEditHistoryForPost, model.AuditStatusSuccess) + defer c.LogAuditRec(auditRec) + model.AddEventParameterToAuditRec(auditRec, "post_id", c.Params.PostId) + + if !isMember { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } + if err := json.NewEncoder(w).Encode(postsList); err != nil { c.Logger.Warn("Error while writing response", mlog.Err(err)) } @@ -629,12 +732,12 @@ func deletePost(c *Context, w http.ResponseWriter, _ *http.Request) { auditRec.AddEventObjectType("post") if c.AppContext.Session().UserId == post.UserId { - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), post.ChannelId, model.PermissionDeletePost) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), post.ChannelId, model.PermissionDeletePost); !ok { c.SetPermissionError(model.PermissionDeletePost) return } } else { - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), post.ChannelId, model.PermissionDeleteOthersPosts) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), post.ChannelId, model.PermissionDeleteOthersPosts); !ok { c.SetPermissionError(model.PermissionDeleteOthersPosts) return } @@ -760,7 +863,8 @@ func getPostThread(c *Context, w http.ResponseWriter, r *http.Request) { return } - if _, err = c.App.GetPostIfAuthorized(c.AppContext, post.Id, c.AppContext.Session(), false); err != nil { + var isMember bool + if _, err, isMember = c.App.GetPostIfAuthorized(c.AppContext, post.Id, c.AppContext.Session(), false); err != nil { c.Err = err return } @@ -770,7 +874,7 @@ func getPostThread(c *Context, w http.ResponseWriter, r *http.Request) { } clientPostList := c.App.PreparePostListForClient(c.AppContext, list) - clientPostList, err = c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId) + clientPostList, isMemberForAllPreviews, err := c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId) if err != nil { c.Err = err return @@ -781,6 +885,17 @@ func getPostThread(c *Context, w http.ResponseWriter, r *http.Request) { if err := clientPostList.EncodeJSON(w); err != nil { c.Logger.Warn("Error while writing response", mlog.Err(err)) } + + auditRec := c.MakeAuditRecord(model.AuditEventGetPostThread, model.AuditStatusSuccess) + defer c.LogAuditRec(auditRec) + model.AddEventParameterToAuditRec(auditRec, "post_id", c.Params.PostId) + + if !isMember || !isMemberForAllPreviews { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + if !isMemberForAllPreviews { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access_on_previews", true) + } + } } func searchPostsInTeam(c *Context, w http.ResponseWriter, r *http.Request) { @@ -845,7 +960,7 @@ func searchPosts(c *Context, w http.ResponseWriter, r *http.Request, teamId stri startTime := time.Now() - results, err := c.App.SearchPostsForUser(c.AppContext, terms, c.AppContext.Session().UserId, teamId, isOrSearch, includeDeletedChannels, timeZoneOffset, page, perPage) + results, allPostHaveMembership, err := c.App.SearchPostsForUser(c.AppContext, terms, c.AppContext.Session().UserId, teamId, isOrSearch, includeDeletedChannels, timeZoneOffset, page, perPage) elapsedTime := float64(time.Since(startTime)) / float64(time.Second) metrics := c.App.Metrics() @@ -860,12 +975,19 @@ func searchPosts(c *Context, w http.ResponseWriter, r *http.Request, teamId stri } clientPostList := c.App.PreparePostListForClient(c.AppContext, results.PostList) - clientPostList, err = c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId) + clientPostList, isMemberForAllPreviews, err := c.App.SanitizePostListMetadataForUser(c.AppContext, clientPostList, c.AppContext.Session().UserId) if err != nil { c.Err = err return } + if !allPostHaveMembership || !isMemberForAllPreviews { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + if !isMemberForAllPreviews { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access_on_previews", true) + } + } + results = model.MakePostSearchResults(clientPostList, results.Matches) model.AddEventParameterAuditableToAuditRec(auditRec, "search_results", results) auditRec.Success() @@ -909,7 +1031,8 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionEditPost) { + ok, isMember := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionEditPost) + if !ok { c.SetPermissionError(model.PermissionEditPost) return } @@ -923,8 +1046,15 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) { post.FileIds = originalPost.FileIds } + // Check upload_file permission only if update is adding NEW files (not just keeping existing ones) + checkUploadFilePermissionForNewFiles(c, post.FileIds, originalPost) + if c.Err != nil { + return + } + if c.AppContext.Session().UserId != originalPost.UserId { - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionEditOthersPosts) { + // We don't need to check the member here, since we already checked it above + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionEditOthersPosts); !ok { c.SetPermissionError(model.PermissionEditOthersPosts) return } @@ -937,12 +1067,22 @@ func updatePost(c *Context, w http.ResponseWriter, r *http.Request) { return } - rpost, err := c.App.UpdatePost(c.AppContext, c.App.PostWithProxyRemovedFromImageURLs(&post), &model.UpdatePostOptions{SafeUpdate: false}) + rpost, isMemberForPreviews, err := c.App.UpdatePost(c.AppContext, c.App.PostWithProxyRemovedFromImageURLs(&post), &model.UpdatePostOptions{SafeUpdate: false}) if err != nil { c.Err = err return } + if !isMember || !isMemberForPreviews { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + if !isMemberForPreviews { + previewPost := rpost.GetPreviewPost() + if previewPost != nil { + model.AddEventParameterToAuditRec(auditRec, "preview_post_id", previewPost.Post.Id) + } + } + } + auditRec.Success() auditRec.AddEventResultState(rpost) @@ -975,17 +1115,34 @@ func patchPost(c *Context, w http.ResponseWriter, r *http.Request) { } } - postPatchChecks(c, auditRec, post.Message) + isMember := postPatchChecks(c, auditRec, post.Message) if c.Err != nil { return } - patchedPost, err := c.App.PatchPost(c.AppContext, c.Params.PostId, c.App.PostPatchWithProxyRemovedFromImageURLs(&post), nil) + originalPost, err := c.App.GetSinglePost(c.AppContext, c.Params.PostId, false) + if err != nil { + c.SetPermissionError(model.PermissionEditPost) + return + } + + if post.FileIds != nil { + checkUploadFilePermissionForNewFiles(c, *post.FileIds, originalPost) + if c.Err != nil { + return + } + } + + patchedPost, isMemberForPReviews, err := c.App.PatchPost(c.AppContext, c.Params.PostId, c.App.PostPatchWithProxyRemovedFromImageURLs(&post), nil) if err != nil { c.Err = err return } + if !isMember || !isMemberForPReviews { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } + auditRec.Success() auditRec.AddEventResultState(patchedPost) @@ -994,11 +1151,11 @@ func patchPost(c *Context, w http.ResponseWriter, r *http.Request) { } } -func postPatchChecks(c *Context, auditRec *model.AuditRecord, message *string) { +func postPatchChecks(c *Context, auditRec *model.AuditRecord, message *string) bool { originalPost, err := c.App.GetSinglePost(c.AppContext, c.Params.PostId, false) if err != nil { c.SetPermissionError(model.PermissionEditPost) - return + return false } auditRec.AddEventPriorState(originalPost) auditRec.AddEventObjectType("post") @@ -1011,15 +1168,18 @@ func postPatchChecks(c *Context, auditRec *model.AuditRecord, message *string) { permission = model.PermissionEditOthersPosts } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, permission) { + ok, isMember := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, permission) + if !ok { c.SetPermissionError(permission) - return + return false } if *c.App.Config().ServiceSettings.PostEditTimeLimit != -1 && model.GetMillis() > originalPost.CreateAt+int64(*c.App.Config().ServiceSettings.PostEditTimeLimit*1000) && message != nil { c.Err = model.NewAppError("patchPost", "api.post.update_post.permissions_time_limit.app_error", map[string]any{"timeLimit": *c.App.Config().ServiceSettings.PostEditTimeLimit}, "", http.StatusBadRequest) - return + return isMember } + + return isMember } func setPostUnread(c *Context, w http.ResponseWriter, r *http.Request) { @@ -1035,7 +1195,7 @@ func setPostUnread(c *Context, w http.ResponseWriter, r *http.Request) { c.SetPermissionError(model.PermissionEditOtherUsers) return } - if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.PostId, model.PermissionReadChannelContent) { + if ok, _ := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), c.Params.PostId); !ok { c.SetPermissionError(model.PermissionReadChannelContent) return } @@ -1060,7 +1220,7 @@ func setPostReminder(c *Context, w http.ResponseWriter, r *http.Request) { c.SetPermissionError(model.PermissionEditOtherUsers) return } - if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.PostId, model.PermissionReadChannelContent) { + if ok, _ := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), c.Params.PostId); !ok { c.SetPermissionError(model.PermissionReadChannelContent) return } @@ -1103,7 +1263,8 @@ func saveIsPinnedPost(c *Context, w http.ResponseWriter, isPinned bool) { c.Err = err return } - if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) { + ok, isMember := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) + if !ok { c.SetPermissionError(model.PermissionReadChannelContent) return } @@ -1111,11 +1272,22 @@ func saveIsPinnedPost(c *Context, w http.ResponseWriter, isPinned bool) { patch := &model.PostPatch{} patch.IsPinned = model.NewPointer(isPinned) - patchedPost, err := c.App.PatchPost(c.AppContext, c.Params.PostId, patch, nil) + patchedPost, isMemberForPreviews, err := c.App.PatchPost(c.AppContext, c.Params.PostId, patch, nil) if err != nil { c.Err = err return } + + if !isMember || !isMemberForPreviews { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + if !isMemberForPreviews { + previewPost := patchedPost.GetPreviewPost() + if previewPost != nil { + model.AddEventParameterToAuditRec(auditRec, "preview_post_id", previewPost.Post.Id) + } + } + } + auditRec.AddEventResultState(patchedPost) auditRec.Success() @@ -1147,7 +1319,7 @@ func acknowledgePost(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.PostId, model.PermissionReadChannelContent) { + if ok, _ := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), c.Params.PostId); !ok { c.SetPermissionError(model.PermissionReadChannelContent) return } @@ -1186,7 +1358,7 @@ func unacknowledgePost(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.PostId, model.PermissionReadChannelContent) { + if ok, _ := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), c.Params.PostId); !ok { c.SetPermissionError(model.PermissionReadChannelContent) return } @@ -1265,7 +1437,7 @@ func moveThread(c *Context, w http.ResponseWriter, r *http.Request) { return } - sourcePost, err := c.App.GetPostIfAuthorized(c.AppContext, c.Params.PostId, c.AppContext.Session(), false) + sourcePost, err, _ := c.App.GetPostIfAuthorized(c.AppContext, c.Params.PostId, c.AppContext.Session(), false) if err != nil { c.Err = err if err.Id == "app.post.cloud.get.app_error" { @@ -1292,7 +1464,8 @@ func getFileInfosForPost(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.PostId, model.PermissionReadChannelContent) { + ok, isMember := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), c.Params.PostId) + if !ok { c.SetPermissionError(model.PermissionReadChannelContent) return } @@ -1319,6 +1492,14 @@ func getFileInfosForPost(c *Context, w http.ResponseWriter, r *http.Request) { return } + auditRec := c.MakeAuditRecord(model.AuditEventGetFileInfosForPost, model.AuditStatusSuccess) + defer c.LogAuditRec(auditRec) + model.AddEventParameterToAuditRec(auditRec, "post_id", c.Params.PostId) + + if !isMember { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } + w.Header().Set("Cache-Control", "max-age=2592000, private") w.Header().Set(model.HeaderEtagServer, model.GetEtagForFileInfos(infos)) if _, err := w.Write(js); err != nil { @@ -1332,7 +1513,86 @@ func getPostInfo(c *Context, w http.ResponseWriter, r *http.Request) { return } - info, appErr := c.App.GetPostInfo(c.AppContext, c.Params.PostId) + userID := c.AppContext.Session().UserId + post, appErr := c.App.GetSinglePost(c.AppContext, c.Params.PostId, false) + if appErr != nil { + c.Err = appErr + return + } + + channel, appErr := c.App.GetChannel(c.AppContext, post.ChannelId) + if appErr != nil { + c.Err = appErr + return + } + + notFoundError := model.NewAppError("GetPostInfo", "app.post.get.app_error", nil, "", http.StatusNotFound) + + var team *model.Team + hasPermissionToAccessTeam := false + if channel.TeamId != "" { + team, appErr = c.App.GetTeam(channel.TeamId) + if appErr != nil { + c.Err = appErr + return + } + + var teamMember *model.TeamMember + teamMember, appErr = c.App.GetTeamMember(c.AppContext, channel.TeamId, userID) + if appErr != nil && appErr.StatusCode != http.StatusNotFound { + c.Err = appErr + return + } + + if appErr == nil { + if teamMember.DeleteAt == 0 { + hasPermissionToAccessTeam = true + } + } + + if !hasPermissionToAccessTeam { + if team.AllowOpenInvite { + hasPermissionToAccessTeam = c.App.HasPermissionToTeam(c.AppContext, userID, team.Id, model.PermissionJoinPublicTeams) + } else { + hasPermissionToAccessTeam = c.App.HasPermissionToTeam(c.AppContext, userID, team.Id, model.PermissionJoinPrivateTeams) + } + } + } else { + // This happens in case of DMs and GMs. + hasPermissionToAccessTeam = true + } + + if !hasPermissionToAccessTeam { + c.Err = notFoundError + return + } + + hasPermissionToAccessChannel := false + hasJoinedChannel := false + + _, channelMemberErr := c.App.GetChannelMember(c.AppContext, channel.Id, userID) + + if channelMemberErr == nil { + hasPermissionToAccessChannel = true + hasJoinedChannel = true + } + + if !hasPermissionToAccessChannel { + if channel.Type == model.ChannelTypeOpen { + hasPermissionToAccessChannel = true + } else if channel.Type == model.ChannelTypePrivate { + hasPermissionToAccessChannel, _ = c.App.HasPermissionToChannel(c.AppContext, userID, channel.Id, model.PermissionManagePrivateChannelMembers) + } else if channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup { + hasPermissionToAccessChannel, _ = c.App.HasPermissionToReadChannel(c.AppContext, userID, channel) + } + } + + if !hasPermissionToAccessChannel { + c.Err = notFoundError + return + } + + info, appErr := c.App.GetPostInfo(c.AppContext, c.Params.PostId, channel, team, userID, hasJoinedChannel) if appErr != nil { c.Err = appErr return @@ -1379,17 +1639,27 @@ func restorePostVersion(c *Context, w http.ResponseWriter, r *http.Request) { return } - postPatchChecks(c, auditRec, &toRestorePost.Message) + isMember := postPatchChecks(c, auditRec, &toRestorePost.Message) if c.Err != nil { return } - updatedPost, appErr := c.App.RestorePostVersion(c.AppContext, c.AppContext.Session().UserId, c.Params.PostId, restoreVersionId) + updatedPost, isMemberForPreview, appErr := c.App.RestorePostVersion(c.AppContext, c.AppContext.Session().UserId, c.Params.PostId, restoreVersionId) if appErr != nil { c.Err = appErr return } + if !isMember || !isMemberForPreview { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + if !isMemberForPreview { + previewPost := updatedPost.GetPreviewPost() + if previewPost != nil { + model.AddEventParameterToAuditRec(auditRec, "preview_post_id", previewPost.Post.Id) + } + } + } + auditRec.Success() auditRec.AddEventResultState(updatedPost) @@ -1469,7 +1739,7 @@ func revealPost(c *Context, w http.ResponseWriter, r *http.Request) { model.AddEventParameterToAuditRec(auditRec, "post_id", postId) model.AddEventParameterToAuditRec(auditRec, "user_id", userId) - post, err := c.App.GetPostIfAuthorized(c.AppContext, postId, c.AppContext.Session(), false) + post, err, isMember := c.App.GetPostIfAuthorized(c.AppContext, postId, c.AppContext.Session(), false) if err != nil { c.Err = err if err.Id == "app.post.cloud.get.app_error" { @@ -1502,6 +1772,10 @@ func revealPost(c *Context, w http.ResponseWriter, r *http.Request) { return } + if !isMember { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } + auditRec.Success() auditRec.AddEventResultState(revealedPost) @@ -1526,7 +1800,7 @@ func burnPost(c *Context, w http.ResponseWriter, r *http.Request) { model.AddEventParameterToAuditRec(auditRec, "post_id", postId) model.AddEventParameterToAuditRec(auditRec, "user_id", userId) - post, err := c.App.GetPostIfAuthorized(c.AppContext, postId, c.AppContext.Session(), false) + post, err, _ := c.App.GetPostIfAuthorized(c.AppContext, postId, c.AppContext.Session(), false) if err != nil { c.Err = err if err.Id == "app.post.cloud.get.app_error" { diff --git a/server/channels/api4/post_test.go b/server/channels/api4/post_test.go index 6a261e4afbf..23b65090ccf 100644 --- a/server/channels/api4/post_test.go +++ b/server/channels/api4/post_test.go @@ -274,6 +274,8 @@ func TestCreatePost(t *testing.T) { }) t.Run("not logged in", func(t *testing.T) { + defer th.LoginBasic(t) + resp, err := client.Logout(context.Background()) require.NoError(t, err) CheckOKStatus(t, resp) @@ -285,6 +287,46 @@ func TestCreatePost(t *testing.T) { assert.Nil(t, rpost) }) + t.Run("should prevent creating post with files when user lacks upload_file permission in target channel", func(t *testing.T) { + fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), th.BasicChannel.Id, "test-file.txt") + require.NoError(t, err) + CheckCreatedStatus(t, resp) + fileId := fileResp.FileInfos[0].Id + + th.RemovePermissionFromRole(t, model.PermissionUploadFile.Id, model.ChannelUserRoleId) + defer func() { + th.AddPermissionToRole(t, model.PermissionUploadFile.Id, model.ChannelUserRoleId) + }() + + post := &model.Post{ + ChannelId: th.BasicChannel.Id, + Message: "Test post with file", + FileIds: model.StringArray{fileId}, + } + rpost, resp, err := client.CreatePost(context.Background(), post) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + assert.Nil(t, rpost) + }) + + t.Run("should allow creating post with files when user has upload_file permission", func(t *testing.T) { + fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), th.BasicChannel.Id, "test-file.txt") + require.NoError(t, err) + CheckCreatedStatus(t, resp) + fileId := fileResp.FileInfos[0].Id + + post := &model.Post{ + ChannelId: th.BasicChannel.Id, + Message: "Test post with file", + FileIds: model.StringArray{fileId}, + } + rpost, resp, err := client.CreatePost(context.Background(), post) + require.NoError(t, err) + CheckCreatedStatus(t, resp) + require.NotNil(t, rpost) + assert.Contains(t, rpost.FileIds, fileId) + }) + t.Run("CreateAt should match the one provided in the request", func(t *testing.T) { post := basicPost() post.CreateAt = 123 @@ -311,6 +353,17 @@ func TestCreatePost(t *testing.T) { require.Nil(t, appErr) require.Zero(t, *createdPost.RemoteId) }) + t.Run("not logged in", func(t *testing.T) { + resp, err := client.Logout(context.Background()) + require.NoError(t, err) + CheckOKStatus(t, resp) + + post := basicPost() + rpost, resp, err := client.CreatePost(context.Background(), post) + require.Error(t, err) + CheckUnauthorizedStatus(t, resp) + assert.Nil(t, rpost) + }) } func TestCreatePostForPriority(t *testing.T) { @@ -1424,7 +1477,7 @@ func TestUpdatePost(t *testing.T) { fileIds[i] = fileResp.FileInfos[0].Id } - rpost, appErr := th.App.CreatePost(th.Context, &model.Post{ + rpost, _, appErr := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: channel.Id, Message: "zz" + model.NewId() + "a", @@ -1457,7 +1510,7 @@ func TestUpdatePost(t *testing.T) { t.Run("join/leave post", func(t *testing.T) { var rpost2 *model.Post - rpost2, appErr = th.App.CreatePost(th.Context, &model.Post{ + rpost2, _, appErr = th.App.CreatePost(th.Context, &model.Post{ ChannelId: channel.Id, Message: "zz" + model.NewId() + "a", Type: model.PostTypeJoinLeave, @@ -1475,7 +1528,7 @@ func TestUpdatePost(t *testing.T) { CheckBadRequestStatus(t, resp) }) - rpost3, appErr := th.App.CreatePost(th.Context, &model.Post{ + rpost3, _, appErr := th.App.CreatePost(th.Context, &model.Post{ ChannelId: channel.Id, Message: "zz" + model.NewId() + "a", UserId: th.BasicUser.Id, @@ -1507,7 +1560,7 @@ func TestUpdatePost(t *testing.T) { *cfg.ServiceSettings.PostEditTimeLimit = -1 }) - rpost4, appErr := th.App.CreatePost(th.Context, &model.Post{ + rpost4, _, appErr := th.App.CreatePost(th.Context, &model.Post{ ChannelId: channel.Id, Message: "zz" + model.NewId() + "a", UserId: th.BasicUser.Id, @@ -1545,6 +1598,62 @@ func TestUpdatePost(t *testing.T) { CheckBadRequestStatus(t, resp) }) + t.Run("should prevent updating post with files when user lacks upload_file permission in target channel", func(t *testing.T) { + postWithoutFiles, _, appErr := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: channel.Id, + Message: "Post without files", + }, channel, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, appErr) + + fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), channel.Id, "test-file.txt") + require.NoError(t, err) + CheckCreatedStatus(t, resp) + fileId := fileResp.FileInfos[0].Id + + th.RemovePermissionFromRole(t, model.PermissionUploadFile.Id, model.ChannelUserRoleId) + defer func() { + th.AddPermissionToRole(t, model.PermissionUploadFile.Id, model.ChannelUserRoleId) + }() + + updatePost := &model.Post{ + Id: postWithoutFiles.Id, + ChannelId: channel.Id, + Message: "Updated post with file", + FileIds: model.StringArray{fileId}, + } + updatedPost, resp, err := client.UpdatePost(context.Background(), postWithoutFiles.Id, updatePost) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + assert.Nil(t, updatedPost) + }) + + t.Run("should allow updating post with files when user has upload_file permission", func(t *testing.T) { + postWithoutFiles, _, appErr := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: channel.Id, + Message: "Post without files", + }, channel, model.CreatePostFlags{SetOnline: true}) + require.Nil(t, appErr) + + fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), channel.Id, "test-file.txt") + require.NoError(t, err) + CheckCreatedStatus(t, resp) + fileId := fileResp.FileInfos[0].Id + + updatePost := &model.Post{ + Id: postWithoutFiles.Id, + ChannelId: channel.Id, + Message: "Updated post with file", + FileIds: model.StringArray{fileId}, + } + updatedPost, resp, err := client.UpdatePost(context.Background(), postWithoutFiles.Id, updatePost) + require.NoError(t, err) + CheckOKStatus(t, resp) + require.NotNil(t, updatedPost) + assert.Contains(t, updatedPost.FileIds, fileId) + }) + t.Run("logged out", func(t *testing.T) { _, err := client.Logout(context.Background()) require.NoError(t, err) @@ -1587,7 +1696,7 @@ func TestUpdatePost(t *testing.T) { fileInfo := fileResponse.FileInfos[0] // create new post - post, appErr := th.App.CreatePost(th.Context, &model.Post{ + post, _, appErr := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: channel.Id, Message: "zz" + model.NewId() + "a", @@ -1623,7 +1732,7 @@ func TestUpdatePost(t *testing.T) { fileInfo := fileResponse.FileInfos[0] // create new post - post, appErr := th.App.CreatePost(th.Context, &model.Post{ + post, _, appErr := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: channel.Id, Message: "zz" + model.NewId() + "a", @@ -1661,7 +1770,7 @@ func TestUpdatePost(t *testing.T) { fileInfo := fileResponse.FileInfos[0] // create new post - post, appErr := th.App.CreatePost(th.Context, &model.Post{ + post, _, appErr := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: channel.Id, Message: "zz" + model.NewId() + "a", @@ -1707,7 +1816,7 @@ func TestUpdatePost(t *testing.T) { fileInfo2 := fileResponse2.FileInfos[0] // create new post - post, appErr := th.App.CreatePost(th.Context, &model.Post{ + post, _, appErr := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: channel.Id, Message: "zz" + model.NewId() + "a", @@ -4320,13 +4429,13 @@ func TestSetChannelUnread(t *testing.T) { t.Run("Unread on a direct channel in a thread", func(t *testing.T) { dc := th.CreateDmChannel(t, th.CreateUser(t)) - rootPost, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: u1.Id, CreateAt: now, ChannelId: dc.Id, Message: "root"}, dc, model.CreatePostFlags{}) + rootPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: u1.Id, CreateAt: now, ChannelId: dc.Id, Message: "root"}, dc, model.CreatePostFlags{}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost.Id, UserId: u1.Id, CreateAt: now + 10, ChannelId: dc.Id, Message: "reply 1"}, dc, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost.Id, UserId: u1.Id, CreateAt: now + 10, ChannelId: dc.Id, Message: "reply 1"}, dc, model.CreatePostFlags{}) require.Nil(t, appErr) - reply2, appErr := th.App.CreatePost(th.Context, &model.Post{RootId: rootPost.Id, UserId: u1.Id, CreateAt: now + 20, ChannelId: dc.Id, Message: "reply 2"}, dc, model.CreatePostFlags{}) + reply2, _, appErr := th.App.CreatePost(th.Context, &model.Post{RootId: rootPost.Id, UserId: u1.Id, CreateAt: now + 20, ChannelId: dc.Id, Message: "reply 2"}, dc, model.CreatePostFlags{}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost.Id, UserId: u1.Id, CreateAt: now + 30, ChannelId: dc.Id, Message: "reply 3"}, dc, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost.Id, UserId: u1.Id, CreateAt: now + 30, ChannelId: dc.Id, Message: "reply 3"}, dc, model.CreatePostFlags{}) require.Nil(t, appErr) // Ensure that post have been read @@ -4425,19 +4534,19 @@ func TestSetPostUnreadWithoutCollapsedThreads(t *testing.T) { // user1: a root post // user2: Another root mention @u1 user1Mention := " @" + th.BasicUser.Username - rootPost1, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "first root mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{}) + rootPost1, _, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "first root mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "hello"}, th.BasicChannel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "hello"}, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) - replyPost1, appErr := th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{}) + replyPost1, _, appErr := th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another reply"}, th.BasicChannel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another reply"}, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "a root post"}, th.BasicChannel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "a root post"}, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another root mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another root mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) t.Run("Mark reply post as unread", func(t *testing.T) { @@ -4541,7 +4650,7 @@ func TestGetEditHistoryForPost(t *testing.T) { UserId: th.BasicUser.Id, } - rpost, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + rpost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) time.Sleep(1 * time.Millisecond) @@ -4612,7 +4721,7 @@ func TestGetEditHistoryForPost(t *testing.T) { FileIds: []string{fileInfo1.Id, fileInfo2.Id}, } - createdPost, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) require.Contains(t, createdPost.FileIds, fileInfo1.Id) require.Contains(t, createdPost.FileIds, fileInfo2.Id) @@ -4766,7 +4875,7 @@ func TestCreatePostNotificationsWithCRT(t *testing.T) { require.NoError(t, err) // post a reply on the thread - _, appErr := th.App.CreatePostAsUser(th.Context, tc.post, th.Context.Session().Id, false) + _, _, appErr := th.App.CreatePostAsUser(th.Context, tc.post, th.Context.Session().Id, false) require.Nil(t, appErr) var caught bool diff --git a/server/channels/api4/post_utils.go b/server/channels/api4/post_utils.go index d135a0fbfd7..edc5ee720d4 100644 --- a/server/channels/api4/post_utils.go +++ b/server/channels/api4/post_utils.go @@ -10,7 +10,7 @@ import ( func userCreatePostPermissionCheckWithContext(c *Context, channelId string) { hasPermission := false - if c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channelId, model.PermissionCreatePost) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channelId, model.PermissionCreatePost); ok { hasPermission = true } else if channel, err := c.App.GetChannel(c.AppContext, channelId); err == nil { // Temporary permission check method until advanced permissions, please do not copy @@ -41,3 +41,31 @@ func postPriorityCheckWithContext(where string, c *Context, priority *model.Post c.Err = appErr } } + +// checkUploadFilePermissionForNewFiles checks upload_file permission only when +// adding new files to a post, preventing permission bypass via cross-channel file attachments. +func checkUploadFilePermissionForNewFiles(c *Context, newFileIds []string, originalPost *model.Post) { + if len(newFileIds) == 0 { + return + } + + originalFileIDsMap := make(map[string]bool, len(originalPost.FileIds)) + for _, fileID := range originalPost.FileIds { + originalFileIDsMap[fileID] = true + } + + hasNewFiles := false + for _, fileID := range newFileIds { + if !originalFileIDsMap[fileID] { + hasNewFiles = true + break + } + } + + if hasNewFiles { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), originalPost.ChannelId, model.PermissionUploadFile); !ok { + c.SetPermissionError(model.PermissionUploadFile) + return + } + } +} diff --git a/server/channels/api4/preference.go b/server/channels/api4/preference.go index fbe16433cea..0bd11fd9773 100644 --- a/server/channels/api4/preference.go +++ b/server/channels/api4/preference.go @@ -131,7 +131,7 @@ func updatePreferences(c *Context, w http.ResponseWriter, r *http.Request) { } } - if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) { + if ok, _ := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel); !ok { c.SetPermissionError(model.PermissionReadChannelContent) return } diff --git a/server/channels/api4/reaction.go b/server/channels/api4/reaction.go index a9fb7c0e7d6..cb67d08a972 100644 --- a/server/channels/api4/reaction.go +++ b/server/channels/api4/reaction.go @@ -57,7 +57,7 @@ func getReactions(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.PostId, model.PermissionReadChannelContent) { + if ok, _ := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), c.Params.PostId); !ok { c.SetPermissionError(model.PermissionReadChannelContent) return } @@ -117,7 +117,7 @@ func getBulkReactions(c *Context, w http.ResponseWriter, r *http.Request) { return } for _, postId := range postIds { - if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), postId, model.PermissionReadChannelContent) { + if ok, _ := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), postId); !ok { c.SetPermissionError(model.PermissionReadChannelContent) return } diff --git a/server/channels/api4/recap.go b/server/channels/api4/recap.go new file mode 100644 index 00000000000..7f17e04ad2c --- /dev/null +++ b/server/channels/api4/recap.go @@ -0,0 +1,286 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "net/http" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/v8/channels/app" +) + +func (api *API) InitRecap() { + api.BaseRoutes.Recaps.Handle("", api.APISessionRequired(createRecap)).Methods(http.MethodPost) + api.BaseRoutes.Recaps.Handle("", api.APISessionRequired(getRecaps)).Methods(http.MethodGet) + api.BaseRoutes.Recaps.Handle("/{recap_id:[A-Za-z0-9]+}", api.APISessionRequired(getRecap)).Methods(http.MethodGet) + api.BaseRoutes.Recaps.Handle("/{recap_id:[A-Za-z0-9]+}/read", api.APISessionRequired(markRecapAsRead)).Methods(http.MethodPost) + api.BaseRoutes.Recaps.Handle("/{recap_id:[A-Za-z0-9]+}/regenerate", api.APISessionRequired(regenerateRecap)).Methods(http.MethodPost) + api.BaseRoutes.Recaps.Handle("/{recap_id:[A-Za-z0-9]+}", api.APISessionRequired(deleteRecap)).Methods(http.MethodDelete) +} + +func requireRecapsEnabled(c *Context) { + if !c.App.Config().FeatureFlags.EnableAIRecaps { + c.Err = model.NewAppError("requireRecapsEnabled", "api.recap.disabled.app_error", nil, "", http.StatusNotImplemented) + return + } +} + +// addRecapChannelIDsToAuditRec extracts channel IDs from a recap and adds them to the audit record. +// This logs which channels' content was accessed through the recap operation. +func addRecapChannelIDsToAuditRec(auditRec *model.AuditRecord, recap *model.Recap) { + if len(recap.Channels) == 0 { + return + } + channelIDs := make([]string, 0, len(recap.Channels)) + for _, channel := range recap.Channels { + channelIDs = append(channelIDs, channel.ChannelId) + } + model.AddEventParameterToAuditRec(auditRec, "channel_ids", channelIDs) +} + +func createRecap(c *Context, w http.ResponseWriter, r *http.Request) { + requireRecapsEnabled(c) + if c.Err != nil { + return + } + + var req model.CreateRecapRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + c.SetInvalidParamWithErr("body", err) + return + } + + if len(req.ChannelIds) == 0 { + c.SetInvalidParam("channel_ids") + return + } + + if req.Title == "" { + c.SetInvalidParam("title") + return + } + + if req.AgentID == "" { + c.SetInvalidParam("agent_id") + return + } + + auditRec := c.MakeAuditRecord(model.AuditEventCreateRecap, model.AuditStatusFail) + defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) + auditRec.AddEventObjectType("recap") + model.AddEventParameterToAuditRec(auditRec, "channel_ids", req.ChannelIds) + model.AddEventParameterToAuditRec(auditRec, "title", req.Title) + model.AddEventParameterToAuditRec(auditRec, "agent_id", req.AgentID) + + recap, err := c.App.CreateRecap(c.AppContext, req.Title, req.ChannelIds, req.AgentID) + if err != nil { + c.Err = err + return + } + + auditRec.Success() + auditRec.AddEventResultState(recap) + + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(recap); err != nil { + c.Logger.Warn("Error encoding response", mlog.Err(err)) + } +} + +func getRecap(c *Context, w http.ResponseWriter, r *http.Request) { + requireRecapsEnabled(c) + if c.Err != nil { + return + } + + c.RequireRecapId() + if c.Err != nil { + return + } + + auditRec := c.MakeAuditRecord(model.AuditEventGetRecap, model.AuditStatusFail) + defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) + auditRec.AddEventObjectType("recap") + model.AddEventParameterToAuditRec(auditRec, "recap_id", c.Params.RecapId) + + recap, err := c.App.GetRecap(c.AppContext, c.Params.RecapId) + if err != nil { + c.Err = err + return + } + + if recap.UserId != c.AppContext.Session().UserId { + c.Err = model.NewAppError("getRecap", "api.recap.permission_denied", nil, "", http.StatusForbidden) + return + } + + // Log channel IDs accessed through viewing this recap summary + addRecapChannelIDsToAuditRec(auditRec, recap) + + auditRec.Success() + auditRec.AddEventResultState(recap) + + if err := json.NewEncoder(w).Encode(recap); err != nil { + c.Logger.Warn("Error encoding response", mlog.Err(err)) + } +} + +func getRecaps(c *Context, w http.ResponseWriter, r *http.Request) { + requireRecapsEnabled(c) + if c.Err != nil { + return + } + + auditRec := c.MakeAuditRecord(model.AuditEventGetRecaps, model.AuditStatusFail) + defer c.LogAuditRecWithLevel(auditRec, app.LevelAPI) + model.AddEventParameterToAuditRec(auditRec, "page", c.Params.Page) + model.AddEventParameterToAuditRec(auditRec, "per_page", c.Params.PerPage) + + recaps, err := c.App.GetRecapsForUser(c.AppContext, c.Params.Page, c.Params.PerPage) + if err != nil { + c.Err = err + return + } + + auditRec.Success() + if len(recaps) > 0 { + auditRec.AddMeta("recap_count", len(recaps)) + } + + if err := json.NewEncoder(w).Encode(recaps); err != nil { + c.Logger.Warn("Error encoding response", mlog.Err(err)) + } +} + +func markRecapAsRead(c *Context, w http.ResponseWriter, r *http.Request) { + requireRecapsEnabled(c) + if c.Err != nil { + return + } + + c.RequireRecapId() + if c.Err != nil { + return + } + + auditRec := c.MakeAuditRecord(model.AuditEventMarkRecapAsRead, model.AuditStatusFail) + defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) + auditRec.AddEventObjectType("recap") + model.AddEventParameterToAuditRec(auditRec, "recap_id", c.Params.RecapId) + + // Check permissions + recap, err := c.App.GetRecap(c.AppContext, c.Params.RecapId) + if err != nil { + c.Err = err + return + } + + if recap.UserId != c.AppContext.Session().UserId { + c.Err = model.NewAppError("markRecapAsRead", "api.recap.permission_denied", nil, "", http.StatusForbidden) + return + } + + auditRec.AddEventPriorState(recap) + + updatedRecap, err := c.App.MarkRecapAsRead(c.AppContext, recap) + if err != nil { + c.Err = err + return + } + + auditRec.Success() + auditRec.AddEventResultState(updatedRecap) + + if err := json.NewEncoder(w).Encode(updatedRecap); err != nil { + c.Logger.Warn("Error encoding response", mlog.Err(err)) + } +} + +func regenerateRecap(c *Context, w http.ResponseWriter, r *http.Request) { + requireRecapsEnabled(c) + if c.Err != nil { + return + } + + c.RequireRecapId() + if c.Err != nil { + return + } + + auditRec := c.MakeAuditRecord(model.AuditEventRegenerateRecap, model.AuditStatusFail) + defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) + auditRec.AddEventObjectType("recap") + model.AddEventParameterToAuditRec(auditRec, "recap_id", c.Params.RecapId) + + // Check permissions + recap, err := c.App.GetRecap(c.AppContext, c.Params.RecapId) + if err != nil { + c.Err = err + return + } + + if recap.UserId != c.AppContext.Session().UserId { + c.Err = model.NewAppError("regenerateRecap", "api.recap.permission_denied", nil, "", http.StatusForbidden) + return + } + + // Log channel IDs that will be re-summarized + addRecapChannelIDsToAuditRec(auditRec, recap) + + auditRec.AddEventPriorState(recap) + + updatedRecap, err := c.App.RegenerateRecap(c.AppContext, c.AppContext.Session().UserId, recap) + if err != nil { + c.Err = err + return + } + + auditRec.Success() + auditRec.AddEventResultState(updatedRecap) + + if err := json.NewEncoder(w).Encode(updatedRecap); err != nil { + c.Logger.Warn("Error encoding response", mlog.Err(err)) + } +} + +func deleteRecap(c *Context, w http.ResponseWriter, r *http.Request) { + requireRecapsEnabled(c) + if c.Err != nil { + return + } + + c.RequireRecapId() + if c.Err != nil { + return + } + + auditRec := c.MakeAuditRecord(model.AuditEventDeleteRecap, model.AuditStatusFail) + defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) + auditRec.AddEventObjectType("recap") + model.AddEventParameterToAuditRec(auditRec, "recap_id", c.Params.RecapId) + + // Check permissions + recap, err := c.App.GetRecap(c.AppContext, c.Params.RecapId) + if err != nil { + c.Err = err + return + } + + if recap.UserId != c.AppContext.Session().UserId { + c.Err = model.NewAppError("deleteRecap", "api.recap.permission_denied", nil, "", http.StatusForbidden) + return + } + + auditRec.AddEventPriorState(recap) + + if err := c.App.DeleteRecap(c.AppContext, c.Params.RecapId); err != nil { + c.Err = err + return + } + + auditRec.Success() + ReturnStatusOK(w) +} diff --git a/server/channels/api4/report_test.go b/server/channels/api4/report_test.go index 41ac902f885..d7e47d798c3 100644 --- a/server/channels/api4/report_test.go +++ b/server/channels/api4/report_test.go @@ -205,7 +205,7 @@ func TestGetPostsForReporting(t *testing.T) { CreateAt: baseTime + (int64(i) * 1000), // 1 second apart UpdateAt: baseTime + (int64(i) * 1000), } - createdPost, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) + createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) testPosts = append(testPosts, createdPost) } @@ -596,7 +596,7 @@ func TestGetPostsForReporting(t *testing.T) { CreateAt: baseTime + (int64(20+i) * 1000), // After all test posts UpdateAt: baseTime + (int64(20+i) * 1000), } - _, appErr := th.App.CreatePost(th.Context, systemPost, th.BasicChannel, model.CreatePostFlags{}) + _, _, appErr := th.App.CreatePost(th.Context, systemPost, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) } diff --git a/server/channels/api4/role_test.go b/server/channels/api4/role_test.go index c84a8539285..3dccdffdb43 100644 --- a/server/channels/api4/role_test.go +++ b/server/channels/api4/role_test.go @@ -6,7 +6,6 @@ package api4 import ( "context" "fmt" - "sort" "strings" "testing" @@ -299,9 +298,8 @@ func TestPatchRole(t *testing.T) { assert.Equal(t, received.Name, role.Name) assert.Equal(t, received.DisplayName, role.DisplayName) assert.Equal(t, received.Description, role.Description) - perms := []string{"create_direct_channel", "create_public_channel", "manage_incoming_webhooks", "manage_outgoing_webhooks"} - sort.Strings(perms) - assert.EqualValues(t, received.Permissions, perms) + expectedPermissions := []string{"create_direct_channel", "create_public_channel", "manage_incoming_webhooks", "manage_outgoing_webhooks"} + assert.ElementsMatch(t, expectedPermissions, received.Permissions) assert.Equal(t, received.SchemeManaged, role.SchemeManaged) // Check a no-op patch succeeds. @@ -333,9 +331,8 @@ func TestPatchRole(t *testing.T) { assert.Equal(t, received.Name, role.Name) assert.Equal(t, received.DisplayName, role.DisplayName) assert.Equal(t, received.Description, role.Description) - perms := []string{"create_direct_channel", "manage_incoming_webhooks", "manage_outgoing_webhooks"} - sort.Strings(perms) - assert.EqualValues(t, received.Permissions, perms) + expectedPermissions := []string{"create_direct_channel", "manage_incoming_webhooks", "manage_outgoing_webhooks"} + assert.ElementsMatch(t, expectedPermissions, received.Permissions) assert.Equal(t, received.SchemeManaged, role.SchemeManaged) t.Run("Check guest permissions editing without E20 license", func(t *testing.T) { diff --git a/server/channels/api4/scheduled_post.go b/server/channels/api4/scheduled_post.go index e366a131fbd..84a9f0818cd 100644 --- a/server/channels/api4/scheduled_post.go +++ b/server/channels/api4/scheduled_post.go @@ -74,6 +74,13 @@ func createSchedulePost(c *Context, w http.ResponseWriter, r *http.Request) { defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) model.AddEventParameterAuditableToAuditRec(auditRec, "scheduledPost", &scheduledPost) + if len(scheduledPost.FileIds) > 0 { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), scheduledPost.ChannelId, model.PermissionUploadFile); !ok { + c.SetPermissionError(model.PermissionUploadFile) + return + } + } + scheduledPostChecks("Api4.createSchedulePost", c, &scheduledPost) if c.Err != nil { return @@ -169,12 +176,38 @@ func updateScheduledPost(c *Context, w http.ResponseWriter, r *http.Request) { defer c.LogAuditRecWithLevel(auditRec, app.LevelContent) model.AddEventParameterAuditableToAuditRec(auditRec, "scheduledPost", &scheduledPost) + userId := c.AppContext.Session().UserId + existingScheduledPost, err := c.App.Srv().Store().ScheduledPost().Get(scheduledPost.Id) + if err != nil { + c.Err = model.NewAppError("updateScheduledPost", "app.update_scheduled_post.get_scheduled_post.error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + if existingScheduledPost == nil { + c.Err = model.NewAppError("updateScheduledPost", "app.update_scheduled_post.existing_scheduled_post.not_exist", nil, "", http.StatusNotFound) + return + } + if existingScheduledPost.UserId != userId { + c.Err = model.NewAppError("updateScheduledPost", "app.update_scheduled_post.update_permission.error", nil, "", http.StatusForbidden) + return + } + + if len(scheduledPost.FileIds) > 0 { + originalPost, err := existingScheduledPost.ToPost() + if err != nil { + c.Err = model.NewAppError("updateScheduledPost", "app.update_scheduled_post.convert_to_post.error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + checkUploadFilePermissionForNewFiles(c, scheduledPost.FileIds, originalPost) + if c.Err != nil { + return + } + } + scheduledPostChecks("Api4.updateScheduledPost", c, &scheduledPost) if c.Err != nil { return } - userId := c.AppContext.Session().UserId updatedScheduledPost, appErr := c.App.UpdateScheduledPost(c.AppContext, userId, &scheduledPost, connectionID) if appErr != nil { c.Err = appErr @@ -209,6 +242,21 @@ func deleteScheduledPost(c *Context, w http.ResponseWriter, r *http.Request) { model.AddEventParameterToAuditRec(auditRec, "scheduledPostId", scheduledPostId) userId := c.AppContext.Session().UserId + + existingScheduledPost, err := c.App.Srv().Store().ScheduledPost().Get(scheduledPostId) + if err != nil { + c.Err = model.NewAppError("deleteScheduledPost", "app.delete_scheduled_post.get_scheduled_post.error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + if existingScheduledPost == nil { + c.Err = model.NewAppError("deleteScheduledPost", "app.delete_scheduled_post.existing_scheduled_post.not_exist", nil, "", http.StatusNotFound) + return + } + if existingScheduledPost.UserId != userId { + c.Err = model.NewAppError("deleteScheduledPost", "app.delete_scheduled_post.delete_permission.error", nil, "", http.StatusForbidden) + return + } + connectionID := r.Header.Get(model.ConnectionId) deletedScheduledPost, appErr := c.App.DeleteScheduledPost(c.AppContext, userId, scheduledPostId, connectionID) if appErr != nil { diff --git a/server/channels/api4/scheduled_post_test.go b/server/channels/api4/scheduled_post_test.go index fc8b024ec18..1e0cc7d50f3 100644 --- a/server/channels/api4/scheduled_post_test.go +++ b/server/channels/api4/scheduled_post_test.go @@ -11,6 +11,88 @@ import ( "github.com/stretchr/testify/require" ) +func TestUpdateScheduledPost(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) + + t.Run("should not allow updating a scheduled post not belonging to the user", func(t *testing.T) { + scheduledPost := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, + } + createdScheduledPost, _, err := th.Client.CreateScheduledPost(context.Background(), scheduledPost) + require.NoError(t, err) + require.NotNil(t, createdScheduledPost) + + originalMessage := createdScheduledPost.Message + originalScheduledAt := createdScheduledPost.ScheduledAt + + createdScheduledPost.ScheduledAt = model.GetMillis() + 9999999 + createdScheduledPost.Message = "Updated Message!!!" + + // Switch to BasicUser2 + th.LoginBasic2(t) + + _, resp, err := th.Client.UpdateScheduledPost(context.Background(), createdScheduledPost) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + // Switch back to original user and verify the post wasn't modified + th.LoginBasic(t) + + fetchedPost, err := th.App.Srv().Store().ScheduledPost().Get(createdScheduledPost.Id) + require.NoError(t, err) + require.NotNil(t, fetchedPost) + require.Equal(t, originalMessage, fetchedPost.Message) + require.Equal(t, originalScheduledAt, fetchedPost.ScheduledAt) + }) +} + +func TestDeleteScheduledPost(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuProfessional)) + + t.Run("should not allow deleting a scheduled post not belonging to the user", func(t *testing.T) { + scheduledPost := &model.ScheduledPost{ + Draft: model.Draft{ + CreateAt: model.GetMillis(), + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "this is a scheduled post", + }, + ScheduledAt: model.GetMillis() + 100000, + } + createdScheduledPost, _, err := th.Client.CreateScheduledPost(context.Background(), scheduledPost) + require.NoError(t, err) + require.NotNil(t, createdScheduledPost) + + // Switch to BasicUser2 + th.LoginBasic2(t) + + _, resp, err := th.Client.DeleteScheduledPost(context.Background(), createdScheduledPost.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + // Switch back to original user and verify the post wasn't deleted + th.LoginBasic(t) + + fetchedPost, err := th.App.Srv().Store().ScheduledPost().Get(createdScheduledPost.Id) + require.NoError(t, err) + require.NotNil(t, fetchedPost) + require.Equal(t, createdScheduledPost.Id, fetchedPost.Id) + require.Equal(t, createdScheduledPost.Message, fetchedPost.Message) + }) +} + func TestCreateScheduledPost(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic(t) diff --git a/server/channels/api4/shared_channel.go b/server/channels/api4/shared_channel.go index afb13444d14..130960b7038 100644 --- a/server/channels/api4/shared_channel.go +++ b/server/channels/api4/shared_channel.go @@ -261,7 +261,7 @@ func getSharedChannelRemotes(c *Context, w http.ResponseWriter, r *http.Request) return } - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok { c.SetPermissionError(model.PermissionReadChannel) return } diff --git a/server/channels/api4/shared_channel_metadata_test.go b/server/channels/api4/shared_channel_metadata_test.go index 8f374f53a5c..a1f43aaabd9 100644 --- a/server/channels/api4/shared_channel_metadata_test.go +++ b/server/channels/api4/shared_channel_metadata_test.go @@ -148,7 +148,7 @@ func TestSharedChannelPostMetadataSync(t *testing.T) { }) // Create a local post with priority metadata - originalPost, appErr := th.App.CreatePost(th.Context, &model.Post{ + originalPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: testChannel.Id, Message: "Test post with priority metadata @" + th.BasicUser2.Username, @@ -203,7 +203,7 @@ func TestSharedChannelPostMetadataSync(t *testing.T) { }) // Create post with acknowledgement request - originalPost, appErr := th.App.CreatePost(th.Context, &model.Post{ + originalPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: testChannel.Id, Message: "Test post requesting acknowledgements @" + th.BasicUser2.Username, @@ -273,7 +273,7 @@ func TestSharedChannelPostMetadataSync(t *testing.T) { }) // Create post with acknowledgement request - originalPost, appErr := th.App.CreatePost(th.Context, &model.Post{ + originalPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: testChannel.Id, Message: "Test post for ack count sync @" + th.BasicUser2.Username, @@ -369,7 +369,7 @@ func TestSharedChannelPostMetadataSync(t *testing.T) { }) // Create post with persistent notifications enabled - _, appErr := th.App.CreatePost(th.Context, &model.Post{ + _, _, appErr := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: testChannel.Id, Message: "Test post with persistent notifications @" + th.BasicUser2.Username, @@ -534,7 +534,7 @@ func TestSharedChannelPostMetadataSync(t *testing.T) { // STEP 1: Server A creates a post with acknowledgement request t.Log("=== STEP 1: Server A creates post with ack request ===") - originalPost, appErr := th.App.CreatePost(th.Context, &model.Post{ + originalPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: testChannel.Id, Message: "Cross-cluster ack test - please acknowledge", diff --git a/server/channels/api4/shared_channel_test.go b/server/channels/api4/shared_channel_test.go index 36aac272f1c..c92d18081d5 100644 --- a/server/channels/api4/shared_channel_test.go +++ b/server/channels/api4/shared_channel_test.go @@ -58,8 +58,6 @@ func TestGetAllSharedChannels(t *testing.T) { require.NoError(t, err) savedIds = append(savedIds, channel.Id) } - sort.Strings(savedIds) - t.Run("get shared channels paginated", func(t *testing.T) { channelIds := make([]string, 0, 21) for i := range pages { @@ -67,10 +65,9 @@ func TestGetAllSharedChannels(t *testing.T) { require.NoError(t, err) channelIds = append(channelIds, getIds(channels)...) } - sort.Strings(channelIds) // ids lists should now match - assert.Equal(t, savedIds, channelIds, "id lists should match") + assert.ElementsMatch(t, savedIds, channelIds, "id lists should match") }) t.Run("get shared channels for invalid team", func(t *testing.T) { diff --git a/server/channels/api4/system.go b/server/channels/api4/system.go index 81cc85c12d8..fce0819847e 100644 --- a/server/channels/api4/system.go +++ b/server/channels/api4/system.go @@ -726,7 +726,9 @@ func pushNotificationAck(c *Context, w http.ResponseWriter, r *http.Request) { } // Return post data only when PostId is passed. if ack.PostId != "" && ack.NotificationType == model.PushTypeMessage { - if _, appErr := c.App.GetPostIfAuthorized(c.AppContext, ack.PostId, c.AppContext.Session(), false); appErr != nil { + var isMember bool + var appErr *model.AppError + if _, appErr, isMember = c.App.GetPostIfAuthorized(c.AppContext, ack.PostId, c.AppContext.Session(), false); appErr != nil { c.Err = appErr return } @@ -746,6 +748,14 @@ func pushNotificationAck(c *Context, w http.ResponseWriter, r *http.Request) { if err2 := json.NewEncoder(w).Encode(msg); err2 != nil { c.Logger.Warn("Error while writing response", mlog.Err(err2)) } + + auditRec := c.MakeAuditRecord(model.AuditEventNotificationAck, model.AuditStatusSuccess) + defer c.LogAuditRec(auditRec) + model.AddEventParameterToAuditRec(auditRec, "post_id", ack.PostId) + + if !isMember { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } } return diff --git a/server/channels/api4/upload.go b/server/channels/api4/upload.go index 203314bfcb8..33055cd03df 100644 --- a/server/channels/api4/upload.go +++ b/server/channels/api4/upload.go @@ -56,7 +56,7 @@ func createUpload(c *Context, w http.ResponseWriter, r *http.Request) { return } } else { - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), us.ChannelId, model.PermissionUploadFile) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), us.ChannelId, model.PermissionUploadFile); !ok { c.SetPermissionError(model.PermissionUploadFile) return } @@ -142,7 +142,10 @@ func uploadData(c *Context, w http.ResponseWriter, r *http.Request) { return } } else { - if us.UserId != c.AppContext.Session().UserId || !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), us.ChannelId, model.PermissionUploadFile) { + if us.UserId != c.AppContext.Session().UserId { + c.SetPermissionError(model.PermissionUploadFile) + return + } else if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), us.ChannelId, model.PermissionUploadFile); !ok { c.SetPermissionError(model.PermissionUploadFile) return } diff --git a/server/channels/api4/user.go b/server/channels/api4/user.go index d474497f826..008c5034e6a 100644 --- a/server/channels/api4/user.go +++ b/server/channels/api4/user.go @@ -922,7 +922,7 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { profiles, appErr = c.App.GetUsersWithoutTeamPage(userGetOptions, c.IsSystemAdmin()) } else if notInChannelId != "" { - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), notInChannelId, model.PermissionReadChannel) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), notInChannelId, model.PermissionReadChannel); !ok { c.SetPermissionError(model.PermissionReadChannel) return } @@ -964,7 +964,7 @@ func getUsers(c *Context, w http.ResponseWriter, r *http.Request) { profiles, appErr = c.App.GetUsersInTeamPage(userGetOptions, c.IsSystemAdmin()) } } else if inChannelId != "" { - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), inChannelId, model.PermissionReadChannel) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), inChannelId, model.PermissionReadChannel); !ok { c.SetPermissionError(model.PermissionReadChannel) return } @@ -1171,14 +1171,18 @@ func searchUsers(c *Context, w http.ResponseWriter, r *http.Request) { } } - if props.InChannelId != "" && !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), props.InChannelId, model.PermissionReadChannel) { - c.SetPermissionError(model.PermissionReadChannel) - return + if props.InChannelId != "" { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), props.InChannelId, model.PermissionReadChannel); !ok { + c.SetPermissionError(model.PermissionReadChannel) + return + } } - if props.NotInChannelId != "" && !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), props.NotInChannelId, model.PermissionReadChannel) { - c.SetPermissionError(model.PermissionReadChannel) - return + if props.NotInChannelId != "" { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), props.NotInChannelId, model.PermissionReadChannel); !ok { + c.SetPermissionError(model.PermissionReadChannel) + return + } } if props.TeamId != "" && !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), props.TeamId, model.PermissionViewTeam) { @@ -1264,7 +1268,7 @@ func autocompleteUsers(c *Context, w http.ResponseWriter, r *http.Request) { } if channelId != "" { - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channelId, model.PermissionReadChannel) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channelId, model.PermissionReadChannel); !ok { c.SetPermissionError(model.PermissionReadChannel) return } @@ -1324,6 +1328,12 @@ func autocompleteUsers(c *Context, w http.ResponseWriter, r *http.Request) { autocomplete.Users = result } + // Fetch agent users for autocomplete + agentUsers, appErr := c.App.GetUsersForAgents(c.AppContext, c.AppContext.Session().UserId) + if appErr == nil && agentUsers != nil { + autocomplete.Agents = agentUsers + } + if err := json.NewEncoder(w).Encode(autocomplete); err != nil { c.Logger.Warn("Error while writing response", mlog.Err(err)) } @@ -2110,6 +2120,11 @@ func loginWithDesktopToken(c *Context, w http.ResponseWriter, r *http.Request) { token := props["token"] deviceId := props["device_id"] + auditRec := c.MakeAuditRecord(model.AuditEventLoginWithDesktopToken, model.AuditStatusFail) + defer c.LogAuditRec(auditRec) + auditRec.AddMeta("login_method", "desktop_token") + model.AddEventParameterToAuditRec(auditRec, "device_id", deviceId) + user, err := c.App.ValidateDesktopToken(token, time.Now().Add(-model.DesktopTokenTTL).Unix()) if err != nil { c.Err = err @@ -2133,6 +2148,9 @@ func loginWithDesktopToken(c *Context, w http.ResponseWriter, r *http.Request) { c.App.AttachSessionCookies(c.AppContext, w, r) + auditRec.Success() + c.LogAuditWithUserId(user.Id, "success") + if err := json.NewEncoder(w).Encode(user); err != nil { c.Logger.Warn("Error while writing response", mlog.Err(err)) } @@ -3196,7 +3214,7 @@ func publishUserTyping(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.HasPermissionToChannel(c.AppContext, c.Params.UserId, typingRequest.ChannelId, model.PermissionCreatePost) { + if ok, _ := c.App.HasPermissionToChannel(c.AppContext, c.Params.UserId, typingRequest.ChannelId, model.PermissionCreatePost); !ok { c.SetPermissionError(model.PermissionCreatePost) return } @@ -3523,7 +3541,8 @@ func getThreadForUser(c *Context, w http.ResponseWriter, r *http.Request) { c.SetPermissionError(model.PermissionEditOtherUsers) return } - if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.ThreadId, model.PermissionReadChannelContent) { + ok, isMember := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), c.Params.ThreadId) + if !ok { c.SetPermissionError(model.PermissionReadChannelContent) return } @@ -3545,6 +3564,14 @@ func getThreadForUser(c *Context, w http.ResponseWriter, r *http.Request) { if err := json.NewEncoder(w).Encode(thread); err != nil { c.Logger.Warn("Error while writing response", mlog.Err(err)) } + + auditRec := c.MakeAuditRecord(model.AuditEventGetThreadForUser, model.AuditStatusSuccess) + defer c.LogAuditRec(auditRec) + model.AddEventParameterToAuditRec(auditRec, "thread_id", c.Params.ThreadId) + + if !isMember { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } } func getThreadsForUser(c *Context, w http.ResponseWriter, r *http.Request) { @@ -3640,11 +3667,16 @@ func updateReadStateThreadByUser(c *Context, w http.ResponseWriter, r *http.Requ c.SetPermissionError(model.PermissionEditOtherUsers) return } - if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.ThreadId, model.PermissionReadChannelContent) { + ok, isMember := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), c.Params.ThreadId) + if !ok { c.SetPermissionError(model.PermissionReadChannelContent) return } + if !isMember { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } + thread, err := c.App.UpdateThreadReadForUser(c.AppContext, c.AppContext.Session().Id, c.Params.UserId, c.Params.TeamId, c.Params.ThreadId, c.Params.Timestamp) if err != nil { c.Err = err @@ -3676,10 +3708,14 @@ func setUnreadThreadByPostId(c *Context, w http.ResponseWriter, r *http.Request) return } - if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.ThreadId, model.PermissionReadChannelContent) { + ok, isMember := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), c.Params.ThreadId) + if !ok { c.SetPermissionError(model.PermissionReadChannelContent) return } + if !isMember { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } // We want to make sure the thread is followed when marking as unread // https://mattermost.atlassian.net/browse/MM-36430 @@ -3718,7 +3754,7 @@ func unfollowThreadByUser(c *Context, w http.ResponseWriter, r *http.Request) { c.SetPermissionError(model.PermissionEditOtherUsers) return } - if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.ThreadId, model.PermissionReadChannelContent) { + if ok, _ := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), c.Params.ThreadId); !ok { c.SetPermissionError(model.PermissionReadChannelContent) return } @@ -3751,7 +3787,7 @@ func followThreadByUser(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToChannelByPost(*c.AppContext.Session(), c.Params.ThreadId, model.PermissionReadChannelContent) { + if ok, _ := c.App.SessionHasPermissionToReadPost(c.AppContext, *c.AppContext.Session(), c.Params.ThreadId); !ok { c.SetPermissionError(model.PermissionReadChannelContent) return } diff --git a/server/channels/api4/user_test.go b/server/channels/api4/user_test.go index 50d82b09985..dc60db4e67c 100644 --- a/server/channels/api4/user_test.go +++ b/server/channels/api4/user_test.go @@ -7202,7 +7202,7 @@ func TestThreadSocketEvents(t *testing.T) { require.NoError(t, err) CheckCreatedStatus(t, resp) - replyPost, appErr := th.App.CreatePostAsUser(th.Context, &model.Post{ChannelId: th.BasicChannel.Id, Message: "testReply @" + th.BasicUser.Username, UserId: th.BasicUser2.Id, RootId: rpost.Id}, th.Context.Session().Id, false) + replyPost, _, appErr := th.App.CreatePostAsUser(th.Context, &model.Post{ChannelId: th.BasicChannel.Id, Message: "testReply @" + th.BasicUser.Username, UserId: th.BasicUser2.Id, RootId: rpost.Id}, th.Context.Session().Id, false) require.Nil(t, appErr) defer func() { err = th.App.Srv().Store().Post().PermanentDeleteByUser(th.Context, th.BasicUser.Id) @@ -7379,7 +7379,7 @@ func TestThreadSocketEvents(t *testing.T) { for _, tc := range testCases { // post a reply on the thread - _, appErr = th.App.CreatePostAsUser(th.Context, tc.post, th.Context.Session().Id, false) + _, _, appErr = th.App.CreatePostAsUser(th.Context, tc.post, th.Context.Session().Id, false) require.Nil(t, appErr) var caught bool @@ -7413,18 +7413,18 @@ func TestThreadSocketEvents(t *testing.T) { rpost2 := &model.Post{ChannelId: th.BasicChannel.Id, UserId: th.BasicUser2.Id, Message: "root post"} var appErr *model.AppError - rpost2, appErr = th.App.CreatePostAsUser(th.Context, rpost2, th.Context.Session().Id, false) + rpost2, _, appErr = th.App.CreatePostAsUser(th.Context, rpost2, th.Context.Session().Id, false) require.Nil(t, appErr) reply1 := &model.Post{ChannelId: th.BasicChannel.Id, UserId: th.BasicUser2.Id, Message: "reply 1", RootId: rpost2.Id} reply2 := &model.Post{ChannelId: th.BasicChannel.Id, UserId: th.BasicUser2.Id, Message: "reply 2", RootId: rpost2.Id} reply3 := &model.Post{ChannelId: th.BasicChannel.Id, UserId: th.BasicUser2.Id, Message: "mention @" + th.BasicUser.Username, RootId: rpost2.Id} - _, appErr = th.App.CreatePostAsUser(th.Context, reply1, th.Context.Session().Id, false) + _, _, appErr = th.App.CreatePostAsUser(th.Context, reply1, th.Context.Session().Id, false) require.Nil(t, appErr) - _, appErr = th.App.CreatePostAsUser(th.Context, reply2, th.Context.Session().Id, false) + _, _, appErr = th.App.CreatePostAsUser(th.Context, reply2, th.Context.Session().Id, false) require.Nil(t, appErr) - _, appErr = th.App.CreatePostAsUser(th.Context, reply3, th.Context.Session().Id, false) + _, _, appErr = th.App.CreatePostAsUser(th.Context, reply3, th.Context.Session().Id, false) require.Nil(t, appErr) count := 0 diff --git a/server/channels/api4/webhook.go b/server/channels/api4/webhook.go index 3b420efe454..3d85b64d0b2 100644 --- a/server/channels/api4/webhook.go +++ b/server/channels/api4/webhook.go @@ -50,7 +50,7 @@ func createIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) { + if ok, _ := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel); !ok { c.LogAudit("fail - bad channel permissions") c.SetPermissionError(model.PermissionReadChannelContent) return @@ -159,10 +159,12 @@ func updateIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) { return } - if channel.Type != model.ChannelTypeOpen && !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) { - c.LogAudit("fail - bad channel permissions") - c.SetPermissionError(model.PermissionReadChannelContent) - return + if channel.Type != model.ChannelTypeOpen { + if ok, _ := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel); !ok { + c.LogAudit("fail - bad channel permissions") + c.SetPermissionError(model.PermissionReadChannelContent) + return + } } if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionBypassIncomingWebhookChannelLock) { @@ -285,8 +287,14 @@ func getIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) { return } - if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), hook.TeamId, model.PermissionManageOwnIncomingWebhooks) || - (channel.Type != model.ChannelTypeOpen && !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)) { + isPrivate := channel.Type != model.ChannelTypeOpen + restrictedChannel := false + if isPrivate { + hasChannelPermission, _ := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) + restrictedChannel = !hasChannelPermission + } + + if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), hook.TeamId, model.PermissionManageOwnIncomingWebhooks) || restrictedChannel { c.LogAudit("fail - bad permissions") c.SetPermissionError(model.PermissionManageOwnIncomingWebhooks) return @@ -339,8 +347,14 @@ func deleteIncomingHook(c *Context, w http.ResponseWriter, r *http.Request) { auditRec.AddMeta("channel_name", channel.Name) auditRec.AddMeta("team_id", hook.TeamId) - if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), hook.TeamId, model.PermissionManageOwnIncomingWebhooks) || - (channel.Type != model.ChannelTypeOpen && !c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel)) { + isPrivate := channel.Type != model.ChannelTypeOpen + restrictedChannel := false + if isPrivate { + hasChannelPermission, _ := c.App.SessionHasPermissionToReadChannel(c.AppContext, *c.AppContext.Session(), channel) + restrictedChannel = !hasChannelPermission + } + + if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), hook.TeamId, model.PermissionManageOwnIncomingWebhooks) || restrictedChannel { c.LogAudit("fail - bad permissions") c.SetPermissionError(model.PermissionManageOwnIncomingWebhooks) return @@ -491,13 +505,13 @@ func getOutgoingHooks(c *Context, w http.ResponseWriter, r *http.Request) { ) if channelID != "" { - if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channelID, model.PermissionManageOwnOutgoingWebhooks) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channelID, model.PermissionManageOwnOutgoingWebhooks); !ok { c.SetPermissionError(model.PermissionManageOwnOutgoingWebhooks) return } // Remove userId as a filter if they have permission to manage others. - if c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channelID, model.PermissionManageOthersOutgoingWebhooks) { + if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channelID, model.PermissionManageOthersOutgoingWebhooks); ok { userID = "" } diff --git a/server/channels/app/access_control.go b/server/channels/app/access_control.go index 1475c4690ea..b17b0b9ca6a 100644 --- a/server/channels/app/access_control.go +++ b/server/channels/app/access_control.go @@ -347,7 +347,7 @@ func (a *App) ValidateChannelAccessControlPermission(rctx request.CTX, userID, c } // Check if user has channel admin permission for the specific channel - if !a.HasPermissionToChannel(rctx, userID, channelID, model.PermissionManageChannelAccessRules) { + if ok, _ := a.HasPermissionToChannel(rctx, userID, channelID, model.PermissionManageChannelAccessRules); !ok { return model.NewAppError("ValidateChannelAccessControlPermission", "app.pap.access_control.insufficient_channel_permissions", nil, "user_id="+userID+" channel_id="+channelID, http.StatusForbidden) } @@ -392,7 +392,7 @@ func (a *App) ValidateAccessControlPolicyPermissionWithOptions(rctx request.CTX, // For read-only operations, allow access to system policies if they're applied to the specific channel if opts.isReadOnly && policy.Type != model.AccessControlPolicyTypeChannel && opts.channelID != "" { // Check if user has access to the channel - if !a.HasPermissionToChannel(rctx, userID, opts.channelID, model.PermissionReadChannel) { + if ok, _ := a.HasPermissionToChannel(rctx, userID, opts.channelID, model.PermissionReadChannel); !ok { return model.NewAppError("ValidateAccessControlPolicyPermissionWithOptions", "app.pap.access_control.insufficient_permissions", nil, "user_id="+userID+" channel_id="+opts.channelID, http.StatusForbidden) } diff --git a/server/channels/app/agents.go b/server/channels/app/agents.go index abb529cbf50..28823235069 100644 --- a/server/channels/app/agents.go +++ b/server/channels/app/agents.go @@ -4,6 +4,8 @@ package app import ( + "net/http" + "github.com/blang/semver/v4" agentclient "github.com/mattermost/mattermost-plugin-ai/public/bridgeclient" @@ -93,12 +95,41 @@ func (a *App) GetAgents(rctx request.CTX, userID string) ([]agentclient.BridgeAg mlog.Err(err), mlog.String("user_id", userID), ) - return nil, model.NewAppError("GetAgents", "app.agents.get_agents.bridge_call_failed", nil, err.Error(), 500) + return nil, model.NewAppError("GetAgents", "app.agents.get_agents.bridge_call_failed", nil, err.Error(), http.StatusInternalServerError) } return agents, nil } +// GetUsersForAgents retrieves the User objects for all available agents +func (a *App) GetUsersForAgents(rctx request.CTX, userID string) ([]*model.User, *model.AppError) { + agents, appErr := a.GetAgents(rctx, userID) + if appErr != nil { + return nil, appErr + } + + if len(agents) == 0 { + return []*model.User{}, nil + } + + users := make([]*model.User, 0, len(agents)) + for _, agent := range agents { + // Agents have a username field that corresponds to the bot user's username + user, err := a.Srv().Store().User().GetByUsername(agent.Username) + if err != nil { + rctx.Logger().Warn("Failed to get user for agent", + mlog.Err(err), + mlog.String("agent_id", agent.ID), + mlog.String("username", agent.Username), + ) + continue + } + users = append(users, user) + } + + return users, nil +} + // GetLLMServices retrieves all available LLM services from the bridge API func (a *App) GetLLMServices(rctx request.CTX, userID string) ([]agentclient.BridgeServiceInfo, *model.AppError) { // Check if the AI plugin is active and supports the bridge API (v1.5.0+) @@ -119,7 +150,7 @@ func (a *App) GetLLMServices(rctx request.CTX, userID string) ([]agentclient.Bri mlog.Err(err), mlog.String("user_id", userID), ) - return nil, model.NewAppError("GetLLMServices", "app.agents.get_services.bridge_call_failed", nil, err.Error(), 500) + return nil, model.NewAppError("GetLLMServices", "app.agents.get_services.bridge_call_failed", nil, err.Error(), http.StatusInternalServerError) } return services, nil diff --git a/server/channels/app/app_test.go b/server/channels/app/app_test.go index 6b8efe7b229..2dc579994a1 100644 --- a/server/channels/app/app_test.go +++ b/server/channels/app/app_test.go @@ -5,7 +5,6 @@ package app import ( "fmt" - "sort" "sync/atomic" "testing" @@ -256,7 +255,6 @@ func TestDoEmojisPermissionsMigration(t *testing.T) { th := SetupWithoutPreloadMigrations(t) expectedSystemAdmin := allPermissionIDs - sort.Strings(expectedSystemAdmin) th.ResetEmojisMigration(t) err := th.App.DoEmojisPermissionsMigration() @@ -279,14 +277,11 @@ func TestDoEmojisPermissionsMigration(t *testing.T) { model.PermissionDeleteEmojis.Id, model.PermissionViewMembers.Id, } - sort.Strings(expected3) - sort.Strings(role3.Permissions) - assert.Equal(t, expected3, role3.Permissions, fmt.Sprintf("'%v' did not have expected permissions", model.SystemUserRoleId)) + assert.ElementsMatch(t, expected3, role3.Permissions, fmt.Sprintf("'%v' did not have expected permissions", model.SystemUserRoleId)) systemAdmin2, systemAdminErr2 := th.App.GetRoleByName(th.Context, model.SystemAdminRoleId) assert.Nil(t, systemAdminErr2) - sort.Strings(systemAdmin2.Permissions) - assert.Equal(t, expectedSystemAdmin, systemAdmin2.Permissions, fmt.Sprintf("'%v' did not have expected permissions", model.SystemAdminRoleId)) + assert.ElementsMatch(t, expectedSystemAdmin, systemAdmin2.Permissions, fmt.Sprintf("'%v' did not have expected permissions", model.SystemAdminRoleId)) } func TestDBHealthCheckWriteAndDelete(t *testing.T) { diff --git a/server/channels/app/authorization.go b/server/channels/app/authorization.go index 40197531857..bca762eb55a 100644 --- a/server/channels/app/authorization.go +++ b/server/channels/app/authorization.go @@ -90,39 +90,53 @@ func (a *App) SessionHasPermissionToTeams(rctx request.CTX, session model.Sessio return true } -func (a *App) SessionHasPermissionToChannel(rctx request.CTX, session model.Session, channelID string, permission *model.Permission) bool { +// SessionHasPermissionToChannel checks if the session has permission to the given channel. +// +// Returns: +// +// (hasPermission, isMember) +// +// hasPermission: true if the user has the specified permission for the channel, otherwise false. +// isMember: used for auditing access without membership. True if the user is a member of the channel, otherwise false. +func (a *App) SessionHasPermissionToChannel(rctx request.CTX, session model.Session, channelID string, permission *model.Permission) (hasPermission bool, isMember bool) { if channelID == "" { - return false + return false, false } channel, appErr := a.GetChannel(rctx, channelID) if appErr != nil && appErr.StatusCode == http.StatusNotFound { - return false + return false, false } else if appErr != nil { rctx.Logger().Warn("Failed to get channel", mlog.String("channel_id", channelID), mlog.Err(appErr)) - return false + return false, false } - if session.IsUnrestricted() || a.RolesGrantPermission(session.GetUserRoles(), model.PermissionManageSystem.Id) { - return true + if session.IsUnrestricted() { + return true, false } + isMember = false ids, err := a.Srv().Store().Channel().GetAllChannelMembersForUser(rctx, session.UserId, true, true) var channelRoles []string if err == nil { if roles, ok := ids[channelID]; ok { + isMember = true channelRoles = strings.Fields(roles) if a.RolesGrantPermission(channelRoles, permission.Id) { - return true + return true, isMember } } } - if channel.TeamId != "" { - return a.SessionHasPermissionToTeam(session, channel.TeamId, permission) + if a.RolesGrantPermission(session.GetUserRoles(), model.PermissionManageSystem.Id) { + return true, isMember } - return a.SessionHasPermissionTo(session, permission) + if channel.TeamId != "" { + return a.SessionHasPermissionToTeam(session, channel.TeamId, permission), isMember + } + + return a.SessionHasPermissionTo(session, permission), isMember } // SessionHasPermissionToChannels returns true only if user has access to all channels. @@ -210,6 +224,21 @@ func (a *App) SessionHasPermissionToChannelByPost(session model.Session, postID return a.SessionHasPermissionTo(session, permission) } +func (a *App) SessionHasPermissionToReadPost(rctx request.CTX, session model.Session, postID string) (hasPErmission bool, isMember bool) { + if postID == "" { + return false, false + } + + channel, err := a.Srv().Store().Channel().GetForPost(postID) + if err != nil { + // Original implementation (SessionHasPermissionToChannelByPost) still checks for + // general permissions even if the channel is not found, and some tests rely on this behavior. + return a.SessionHasPermissionTo(session, model.PermissionReadChannelContent), false + } + + return a.SessionHasPermissionToReadChannel(rctx, session, channel) +} + func (a *App) SessionHasPermissionToCategory(rctx request.CTX, session model.Session, userID, teamID, categoryId string) bool { if a.SessionHasPermissionTo(session, model.PermissionEditOtherUsers) { return true @@ -287,11 +316,21 @@ func (a *App) HasPermissionToTeam(rctx request.CTX, askingUserId string, teamID return a.HasPermissionTo(askingUserId, permission) } -func (a *App) HasPermissionToChannel(rctx request.CTX, askingUserId string, channelID string, permission *model.Permission) bool { +// HasPermissionToChannel determines if the specified user has the given permission on the provided channel. +// +// Returns: +// +// (hasPermission, isMember) +// +// hasPermission: true if the user has the specified permission for the channel, otherwise false. +// isMember: used for auditing access without membership. True if the user is a member of the channel, otherwise false. +func (a *App) HasPermissionToChannel(rctx request.CTX, askingUserId string, channelID string, permission *model.Permission) (hasPermission bool, isMember bool) { if channelID == "" || askingUserId == "" { - return false + return false, false } + isMember = false + // We call GetAllChannelMembersForUser instead of just getting // a single member from the DB, because it's cache backed // and this is a very frequent call. @@ -299,19 +338,20 @@ func (a *App) HasPermissionToChannel(rctx request.CTX, askingUserId string, chan var channelRoles []string if err == nil { if roles, ok := ids[channelID]; ok { + isMember = true channelRoles = strings.Fields(roles) if a.RolesGrantPermission(channelRoles, permission.Id) { - return true + return true, isMember } } } channel, appErr := a.GetChannel(rctx, channelID) if appErr == nil && channel.TeamId != "" { - return a.HasPermissionToTeam(rctx, askingUserId, channel.TeamId, permission) + return a.HasPermissionToTeam(rctx, askingUserId, channel.TeamId, permission), isMember } - return a.HasPermissionTo(askingUserId, permission) + return a.HasPermissionTo(askingUserId, permission), isMember } func (a *App) HasPermissionToChannelByPost(rctx request.CTX, askingUserId string, postID string, permission *model.Permission) bool { @@ -398,28 +438,45 @@ func (a *App) SessionHasPermissionToManageBot(rctx request.CTX, session model.Se return nil } -func (a *App) SessionHasPermissionToReadChannel(rctx request.CTX, session model.Session, channel *model.Channel) bool { +// SessionHasPermissionToReadChannel checks whether the given session has permission +// to read the specified channel. +// +// Returns: +// +// (hasPermission, isMember) +// +// hasPermission: true if the user has permission to read the channel, false otherwise +// isMember: used for auditing access without membership. True if the user is a member of the channel, false otherwise +func (a *App) SessionHasPermissionToReadChannel(rctx request.CTX, session model.Session, channel *model.Channel) (hasPermission bool, isMember bool) { if session.IsUnrestricted() { - return true + return true, false } return a.HasPermissionToReadChannel(rctx, session.UserId, channel) } -func (a *App) HasPermissionToReadChannel(rctx request.CTX, userID string, channel *model.Channel) bool { - if a.HasPermissionToChannel(rctx, userID, channel.Id, model.PermissionReadChannelContent) { - return true +// HasPermissionToReadChannel determines if the specified user has permission to read the given channel. +// +// Returns: +// +// (hasPermission, isMember) +// +// hasPermission: true if the user has permission to read the channel, false otherwise +// isMember: used for auditing access without membership. True if the user is a member of the channel, false otherwise +func (a *App) HasPermissionToReadChannel(rctx request.CTX, userID string, channel *model.Channel) (hasPermission bool, isMember bool) { + if ok, member := a.HasPermissionToChannel(rctx, userID, channel.Id, model.PermissionReadChannelContent); ok { + return true, member } if channel.Type == model.ChannelTypeOpen && !*a.Config().ComplianceSettings.Enable { - return a.HasPermissionToTeam(rctx, userID, channel.TeamId, model.PermissionReadPublicChannel) + return a.HasPermissionToTeam(rctx, userID, channel.TeamId, model.PermissionReadPublicChannel), false } - return false + return false, false } func (a *App) HasPermissionToChannelMemberCount(rctx request.CTX, userID string, channel *model.Channel) bool { - if a.HasPermissionToChannel(rctx, userID, channel.Id, model.PermissionReadChannelContent) { + if ok, _ := a.HasPermissionToChannel(rctx, userID, channel.Id, model.PermissionReadChannelContent); ok { return true } diff --git a/server/channels/app/authorization_test.go b/server/channels/app/authorization_test.go index c4109c10b6c..64214c254db 100644 --- a/server/channels/app/authorization_test.go +++ b/server/channels/app/authorization_test.go @@ -231,13 +231,28 @@ func TestSessionHasPermissionToChannel(t *testing.T) { } t.Run("basic user can access basic channel", func(t *testing.T) { - assert.True(t, th.App.SessionHasPermissionToChannel(th.Context, session, th.BasicChannel.Id, model.PermissionAddReaction)) + ok, isMember := th.App.SessionHasPermissionToChannel(th.Context, session, th.BasicChannel.Id, model.PermissionAddReaction) + assert.True(t, ok) + assert.True(t, isMember) }) t.Run("basic user can access archived channel", func(t *testing.T) { err := th.App.DeleteChannel(th.Context, th.BasicChannel, th.SystemAdminUser.Id) require.Nil(t, err) - assert.True(t, th.App.SessionHasPermissionToChannel(th.Context, session, th.BasicChannel.Id, model.PermissionReadChannel)) + ok, isMember := th.App.SessionHasPermissionToChannel(th.Context, session, th.BasicChannel.Id, model.PermissionReadChannel) + assert.True(t, ok) + assert.True(t, isMember) + }) + + t.Run("admin user can access channel if not a member", func(t *testing.T) { + adminSession := model.Session{ + UserId: th.SystemAdminUser.Id, + Roles: model.SystemAdminRoleId, + } + + ok, isMember := th.App.SessionHasPermissionToChannel(th.Context, adminSession, th.BasicChannel.Id, model.PermissionAddReaction) + assert.True(t, ok) + assert.False(t, isMember) }) t.Run("does not panic if fetching channel causes an error", func(t *testing.T) { @@ -268,7 +283,9 @@ func TestSessionHasPermissionToChannel(t *testing.T) { // If there's an error returned from the GetChannel call the code should continue to cascade and since there // are no session level permissions in this test case, the permission should be denied. - assert.False(t, th.App.SessionHasPermissionToChannel(th.Context, session, th.BasicUser.Id, model.PermissionAddReaction)) + ok, isMember := th.App.SessionHasPermissionToChannel(th.Context, session, th.BasicUser.Id, model.PermissionAddReaction) + assert.False(t, ok) + assert.False(t, isMember) }) } @@ -710,6 +727,7 @@ func TestHasPermissionToReadChannel(t *testing.T) { channelIsOpen bool canReadPublicChannel bool expected bool + isAdmin bool }{ { name: "Can read archived channels", @@ -765,10 +783,25 @@ func TestHasPermissionToReadChannel(t *testing.T) { canReadPublicChannel: true, expected: true, }, + { + name: "Can read private channels if it is a sysadmin and it is not member of the channel", + configComplianceEnabled: false, + channelDeleted: false, + canReadChannel: false, + channelIsOpen: false, + canReadPublicChannel: false, + expected: true, + isAdmin: true, + }, } for _, tc := range ttcc { t.Run(tc.name, func(t *testing.T) { + user := th.BasicUser2 + if tc.isAdmin { + user = th.SystemAdminUser + } + th.App.UpdateConfig(func(cfg *model.Config) { configComplianceEnabled := tc.configComplianceEnabled cfg.ComplianceSettings.Enable = &configComplianceEnabled @@ -786,7 +819,7 @@ func TestHasPermissionToReadChannel(t *testing.T) { channel = th.CreatePrivateChannel(t, team) } if tc.canReadChannel { - _, err := th.App.AddUserToChannel(th.Context, th.BasicUser2, channel, false) + _, err := th.App.AddUserToChannel(th.Context, user, channel, false) require.Nil(t, err) } @@ -797,8 +830,13 @@ func TestHasPermissionToReadChannel(t *testing.T) { require.Nil(t, err) } - result := th.App.HasPermissionToReadChannel(th.Context, th.BasicUser2.Id, channel) + result, isMember := th.App.HasPermissionToReadChannel(th.Context, user.Id, channel) require.Equal(t, tc.expected, result) + if result { + require.Equal(t, tc.canReadChannel, isMember) + } else { + require.Equal(t, false, isMember) + } }) } } @@ -888,3 +926,226 @@ func TestHasPermissionToChannelByPost(t *testing.T) { require.Equal(t, true, th.App.HasPermissionToChannelByPost(th.Context, th.SystemAdminUser.Id, post.Id, model.PermissionReadChannel)) }) } + +func TestHasPermissionToChannel(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + channel := th.CreateChannel(t, th.BasicTeam) + _, appErr := th.App.AddUserToChannel(th.Context, th.BasicUser, channel, false) + assert.Nil(t, appErr) + + archivedChannel := th.CreateChannel(t, th.BasicTeam) + _, appErr = th.App.AddUserToChannel(th.Context, th.BasicUser, archivedChannel, false) + assert.Nil(t, appErr) + appErr = th.App.DeleteChannel(th.Context, archivedChannel, th.SystemAdminUser.Id) + assert.Nil(t, appErr) + + t.Run("read channel", func(t *testing.T) { + ok, isMember := th.App.HasPermissionToChannel(th.Context, th.BasicUser.Id, channel.Id, model.PermissionReadChannel) + assert.True(t, ok) + assert.True(t, isMember) + + ok, isMember = th.App.HasPermissionToChannel(th.Context, th.BasicUser2.Id, channel.Id, model.PermissionReadChannel) + assert.False(t, ok) + assert.False(t, isMember) + }) + + t.Run("read archived channel", func(t *testing.T) { + ok, isMember := th.App.HasPermissionToChannel(th.Context, th.BasicUser.Id, archivedChannel.Id, model.PermissionReadChannel) + assert.True(t, ok) + assert.True(t, isMember) + + ok, isMember = th.App.HasPermissionToChannel(th.Context, th.BasicUser2.Id, archivedChannel.Id, model.PermissionReadChannel) + assert.False(t, ok) + assert.False(t, isMember) + }) + + t.Run("read public channel", func(t *testing.T) { + ok, isMember := th.App.HasPermissionToChannel(th.Context, th.BasicUser.Id, channel.Id, model.PermissionReadPublicChannel) + assert.True(t, ok) + assert.True(t, isMember) + + ok, isMember = th.App.HasPermissionToChannel(th.Context, th.BasicUser2.Id, channel.Id, model.PermissionReadPublicChannel) + assert.True(t, ok) + assert.False(t, isMember) + }) + + t.Run("read channel - user is admin", func(t *testing.T) { + ok, isMember := th.App.HasPermissionToChannel(th.Context, th.SystemAdminUser.Id, channel.Id, model.PermissionReadChannel) + assert.True(t, ok) + assert.False(t, isMember) + }) +} + +func TestSessionHasPermissionToReadChannel(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + channel := th.CreateChannel(t, th.BasicTeam) + _, appErr := th.App.AddUserToChannel(th.Context, th.BasicUser, channel, false) + assert.Nil(t, appErr) + + archivedChannel := th.CreateChannel(t, th.BasicTeam) + _, appErr = th.App.AddUserToChannel(th.Context, th.BasicUser, archivedChannel, false) + assert.Nil(t, appErr) + appErr = th.App.DeleteChannel(th.Context, archivedChannel, th.SystemAdminUser.Id) + assert.Nil(t, appErr) + + t.Run("basic user can read channel", func(t *testing.T) { + session := model.Session{ + UserId: th.BasicUser.Id, + } + ok, isMember := th.App.SessionHasPermissionToReadChannel(th.Context, session, channel) + assert.True(t, ok) + assert.True(t, isMember) + }) + + t.Run("basic user cannot read channel if not a member and not public", func(t *testing.T) { + privateChannel := th.CreatePrivateChannel(t, th.BasicTeam) + session := model.Session{ + UserId: th.BasicUser2.Id, + } + ok, isMember := th.App.SessionHasPermissionToReadChannel(th.Context, session, privateChannel) + assert.False(t, ok) + assert.False(t, isMember) + }) + + t.Run("basic user can read archived channel if member", func(t *testing.T) { + session := model.Session{ + UserId: th.BasicUser.Id, + } + ok, isMember := th.App.SessionHasPermissionToReadChannel(th.Context, session, archivedChannel) + assert.True(t, ok) + assert.True(t, isMember) + }) + + t.Run("non-member can read public channel", func(t *testing.T) { + session := model.Session{ + UserId: th.BasicUser2.Id, + } + ok, isMember := th.App.SessionHasPermissionToReadChannel(th.Context, session, channel) + assert.True(t, ok) + assert.False(t, isMember) + }) + + t.Run("admin can read any channel", func(t *testing.T) { + session := model.Session{ + UserId: th.SystemAdminUser.Id, + Roles: model.SystemAdminRoleId, + } + ok, isMember := th.App.SessionHasPermissionToReadChannel(th.Context, session, channel) + assert.True(t, ok) + assert.False(t, isMember) + }) +} + +func TestSessionHasPermissionToReadPost(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + // Create a post in a public channel, ensure basic user can read it. + post, _, err := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: th.BasicChannel.Id, + Message: "hello world", + }, th.BasicChannel, model.CreatePostFlags{}) + require.Nil(t, err) + + t.Run("basic user can read their post", func(t *testing.T) { + session := model.Session{ + UserId: th.BasicUser.Id, + } + ok, isMember := th.App.SessionHasPermissionToReadPost(th.Context, session, post.Id) + assert.True(t, ok) + assert.True(t, isMember) + }) + + t.Run("other member in channel can read post", func(t *testing.T) { + // Add BasicUser2 to channel + _, aerr := th.App.AddUserToChannel(th.Context, th.BasicUser2, th.BasicChannel, false) + require.Nil(t, aerr) + session := model.Session{ + UserId: th.BasicUser2.Id, + } + ok, isMember := th.App.SessionHasPermissionToReadPost(th.Context, session, post.Id) + assert.True(t, ok) + assert.True(t, isMember) + }) + + t.Run("non-member can read post in public channel", func(t *testing.T) { + // Remove BasicUser2 from channel + aerr := th.App.removeUserFromChannel(th.Context, th.BasicUser2.Id, th.SystemAdminUser.Id, th.BasicChannel) + assert.Nil(t, aerr) + session := model.Session{ + UserId: th.BasicUser2.Id, + } + ok, isMember := th.App.SessionHasPermissionToReadPost(th.Context, session, post.Id) + assert.True(t, ok) + assert.False(t, isMember) + }) + + t.Run("non-member cannot read post in private channel", func(t *testing.T) { + privateChan := th.CreatePrivateChannel(t, th.BasicTeam) + privatePost, _, err := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: privateChan.Id, + Message: "private message", + }, privateChan, model.CreatePostFlags{}) + require.Nil(t, err) + + session := model.Session{ + UserId: th.BasicUser2.Id, + } + ok, isMember := th.App.SessionHasPermissionToReadPost(th.Context, session, privatePost.Id) + assert.False(t, ok) + assert.False(t, isMember) + }) + + t.Run("admin can read post even if not a channel member", func(t *testing.T) { + privateChan := th.CreatePrivateChannel(t, th.BasicTeam) + privatePost, _, err := th.App.CreatePost(th.Context, &model.Post{ + UserId: th.BasicUser.Id, + ChannelId: privateChan.Id, + Message: "private admin", + }, privateChan, model.CreatePostFlags{}) + require.Nil(t, err) + + session := model.Session{ + UserId: th.SystemAdminUser.Id, + Roles: model.SystemAdminRoleId, + } + ok, isMember := th.App.SessionHasPermissionToReadPost(th.Context, session, privatePost.Id) + assert.True(t, ok) + assert.False(t, isMember) + }) + + t.Run("returns false for empty postID", func(t *testing.T) { + session := model.Session{ + UserId: th.BasicUser.Id, + } + ok, isMember := th.App.SessionHasPermissionToReadPost(th.Context, session, "") + assert.False(t, ok) + assert.False(t, isMember) + }) + + t.Run("returns permission based on system level if postID is missing", func(t *testing.T) { + // To simulate a missing post, use a postID that doesn't exist + session := model.Session{ + UserId: th.SystemAdminUser.Id, + Roles: model.SystemAdminRoleId, + } + + ok, isMember := th.App.SessionHasPermissionToReadPost(th.Context, session, model.NewId()) + assert.True(t, ok) + assert.False(t, isMember) + + // Basic user, should be false + session = model.Session{ + UserId: th.BasicUser2.Id, + } + ok, isMember = th.App.SessionHasPermissionToReadPost(th.Context, session, model.NewId()) + assert.False(t, ok) + assert.False(t, isMember) + }) +} diff --git a/server/channels/app/auto_responder.go b/server/channels/app/auto_responder.go index 8c6c42a0ddc..fd7be6fd29d 100644 --- a/server/channels/app/auto_responder.go +++ b/server/channels/app/auto_responder.go @@ -77,7 +77,7 @@ func (a *App) SendAutoResponse(rctx request.CTX, channel *model.Channel, receive UserId: receiver.Id, } - if _, err := a.CreatePost(rctx, autoResponderPost, channel, model.CreatePostFlags{}); err != nil { + if _, _, err := a.CreatePost(rctx, autoResponderPost, channel, model.CreatePostFlags{}); err != nil { return false, err } diff --git a/server/channels/app/auto_responder_test.go b/server/channels/app/auto_responder_test.go index 79ac4477412..471dcaef54f 100644 --- a/server/channels/app/auto_responder_test.go +++ b/server/channels/app/auto_responder_test.go @@ -107,7 +107,7 @@ func TestSendAutoResponseIfNecessary(t *testing.T) { channel := th.CreateDmChannel(t, receiver) - savedPost, _ := th.App.CreatePost(th.Context, &model.Post{ + savedPost, _, _ := th.App.CreatePost(th.Context, &model.Post{ ChannelId: channel.Id, Message: NewTestId(), UserId: th.BasicUser.Id, @@ -137,7 +137,7 @@ func TestSendAutoResponseIfNecessary(t *testing.T) { channel := th.CreateDmChannel(t, receiver) - savedPost, _ := th.App.CreatePost(th.Context, &model.Post{ + savedPost, _, _ := th.App.CreatePost(th.Context, &model.Post{ ChannelId: channel.Id, Message: NewTestId(), UserId: th.BasicUser.Id, @@ -154,7 +154,7 @@ func TestSendAutoResponseIfNecessary(t *testing.T) { t.Run("should not send auto response for non-DM channel", func(t *testing.T) { th := Setup(t).InitBasic(t) - savedPost, _ := th.App.CreatePost(th.Context, &model.Post{ + savedPost, _, _ := th.App.CreatePost(th.Context, &model.Post{ ChannelId: th.BasicChannel.Id, Message: NewTestId(), UserId: th.BasicUser.Id, @@ -194,7 +194,7 @@ func TestSendAutoResponseIfNecessary(t *testing.T) { botUser, err := th.App.GetUser(bot.UserId) assert.Nil(t, err) - savedPost, _ := th.App.CreatePost(th.Context, &model.Post{ + savedPost, _, _ := th.App.CreatePost(th.Context, &model.Post{ ChannelId: channel.Id, Message: NewTestId(), UserId: botUser.Id, @@ -229,7 +229,7 @@ func TestSendAutoResponseIfNecessary(t *testing.T) { // which needs to be cleaned up. require.NoError(t, th.GetSqlStore().Post().PermanentDeleteByUser(th.Context, th.BasicUser.Id)) - savedPost, err := th.App.CreatePost(th.Context, &model.Post{ + savedPost, _, err := th.App.CreatePost(th.Context, &model.Post{ ChannelId: channel.Id, Message: patch.NotifyProps["auto_responder_message"], UserId: receiver.Id, @@ -266,7 +266,7 @@ func TestSendAutoResponseSuccess(t *testing.T) { userUpdated1, err := th.App.PatchUser(th.Context, user.Id, patch, true) require.Nil(t, err) - savedPost, _ := th.App.CreatePost(th.Context, &model.Post{ + savedPost, _, _ := th.App.CreatePost(th.Context, &model.Post{ ChannelId: th.BasicChannel.Id, Message: "zz" + model.NewId() + "a", UserId: th.BasicUser.Id, @@ -310,7 +310,7 @@ func TestSendAutoResponseSuccessOnThread(t *testing.T) { userUpdated1, err := th.App.PatchUser(th.Context, user.Id, patch, true) require.Nil(t, err) - parentPost, _ := th.App.CreatePost(th.Context, &model.Post{ + parentPost, _, _ := th.App.CreatePost(th.Context, &model.Post{ ChannelId: th.BasicChannel.Id, Message: "zz" + model.NewId() + "a", UserId: th.BasicUser.Id, @@ -318,7 +318,7 @@ func TestSendAutoResponseSuccessOnThread(t *testing.T) { th.BasicChannel, model.CreatePostFlags{SetOnline: true}) - savedPost, _ := th.App.CreatePost(th.Context, &model.Post{ + savedPost, _, _ := th.App.CreatePost(th.Context, &model.Post{ ChannelId: th.BasicChannel.Id, Message: "zz" + model.NewId() + "a", UserId: th.BasicUser.Id, @@ -363,7 +363,7 @@ func TestSendAutoResponseFailure(t *testing.T) { userUpdated1, err := th.App.PatchUser(th.Context, user.Id, patch, true) require.Nil(t, err) - savedPost, _ := th.App.CreatePost(th.Context, &model.Post{ + savedPost, _, _ := th.App.CreatePost(th.Context, &model.Post{ ChannelId: th.BasicChannel.Id, Message: "zz" + model.NewId() + "a", UserId: th.BasicUser.Id, diff --git a/server/channels/app/bot.go b/server/channels/app/bot.go index 42357ef140a..09c036a6904 100644 --- a/server/channels/app/bot.go +++ b/server/channels/app/bot.go @@ -157,7 +157,7 @@ func (a *App) CreateBot(rctx request.CTX, bot *model.Bot) (*model.Bot, *model.Ap Message: T("api.bot.teams_channels.add_message_mobile"), } - if _, err := a.CreatePostAsUser(rctx, botAddPost, rctx.Session().Id, true); err != nil { + if _, _, err := a.CreatePostAsUser(rctx, botAddPost, rctx.Session().Id, true); err != nil { return nil, err } } @@ -599,7 +599,7 @@ func (a *App) notifySysadminsBotOwnerDeactivated(rctx request.CTX, userID string Type: model.PostTypeSystemGeneric, } - _, appErr = a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}) + _, _, appErr = a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}) if appErr != nil { return appErr } diff --git a/server/channels/app/channel.go b/server/channels/app/channel.go index b298d82064e..e50a30f5147 100644 --- a/server/channels/app/channel.go +++ b/server/channels/app/channel.go @@ -851,7 +851,7 @@ func (a *App) postChannelPrivacyMessage(rctx request.CTX, user *model.User, chan }, } - if _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { + if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { return model.NewAppError("postChannelPrivacyMessage", "api.channel.post_channel_privacy_message.error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -906,7 +906,7 @@ func (a *App) RestoreChannel(rctx request.CTX, channel *model.Channel, userID st }, } - if _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { + if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { rctx.Logger().Warn("Failed to post unarchive message", mlog.Err(err)) } } else { @@ -927,7 +927,7 @@ func (a *App) RestoreChannel(rctx request.CTX, channel *model.Channel, userID st }, } - if _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { + if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { rctx.Logger().Error("Failed to post unarchive message", mlog.Err(err)) } }) @@ -1262,7 +1262,22 @@ func buildChannelModerations(rctx request.CTX, channelType model.ChannelType, me return channelModerations } +// UpdateChannelMemberRoles updates the roles for a channel member. +// This is the public API used by REST endpoints and plugins. +// It enforces strict validation requiring either SchemeUser or SchemeGuest to be true. func (a *App) UpdateChannelMemberRoles(rctx request.CTX, channelID string, userID string, newRoles string) (*model.ChannelMember, *model.AppError) { + return a.updateChannelMemberRolesInternal(rctx, channelID, userID, newRoles, false) +} + +// updateChannelMemberRolesInternal is the internal implementation of UpdateChannelMemberRoles. +// The allowSchemeUserUnset parameter controls whether to enforce the requirement that members +// must have either SchemeUser or SchemeGuest set to true. +// +// When allowSchemeUserUnset is false (default for API/plugin calls), the function enforces +// that members must have a base scheme role. When true (bulk import only), this validation +// is skipped to support the two-phase import pattern where explicit roles are set first, +// then scheme roles are set via UpdateChannelMemberSchemeRoles immediately after. +func (a *App) updateChannelMemberRolesInternal(rctx request.CTX, channelID string, userID string, newRoles string, allowSchemeUserUnset bool) (*model.ChannelMember, *model.AppError) { var member *model.ChannelMember var err *model.AppError if member, err = a.GetChannelMember(rctx, channelID, userID); err != nil { @@ -1316,6 +1331,13 @@ func (a *App) UpdateChannelMemberRoles(rctx request.CTX, channelID string, userI return nil, model.NewAppError("UpdateChannelMemberRoles", "api.channel.update_channel_member_roles.changing_guest_role.app_error", nil, "", http.StatusBadRequest) } + // Validate that the member has a base scheme role (SchemeUser or SchemeGuest). + // This ensures members always have the minimum required permissions. + // Bulk import operations may skip this validation temporarily. + if !allowSchemeUserUnset && !member.SchemeGuest && !member.SchemeUser { + return nil, model.NewAppError("UpdateChannelMemberRoles", "api.channel.update_channel_member_roles.unset_user_scheme.app_error", nil, "", http.StatusBadRequest) + } + member.ExplicitRoles = strings.Join(newExplicitRoles, " ") return a.updateChannelMember(rctx, member) @@ -1571,7 +1593,7 @@ func (a *App) DeleteChannel(rctx request.CTX, channel *model.Channel, userID str }, } - if _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { + if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { rctx.Logger().Warn("Failed to post archive message", mlog.Err(err)) } } else { @@ -1589,7 +1611,7 @@ func (a *App) DeleteChannel(rctx request.CTX, channel *model.Channel, userID str }, } - if _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { + if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { rctx.Logger().Warn("Failed to post archive message", mlog.Err(err)) } } @@ -1904,7 +1926,7 @@ func (a *App) PostUpdateChannelHeaderMessage(rctx request.CTX, userID string, ch }, } - if _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { + if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { return model.NewAppError("", "api.channel.post_update_channel_header_message_and_forget.post.error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -1937,7 +1959,7 @@ func (a *App) PostUpdateChannelPurposeMessage(rctx request.CTX, userID string, c "new_purpose": newChannelPurpose, }, } - if _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { + if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { return model.NewAppError("", "app.channel.post_update_channel_purpose_message.post.error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -1964,7 +1986,7 @@ func (a *App) PostUpdateChannelDisplayNameMessage(rctx request.CTX, userID strin }, } - if _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { + if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { return model.NewAppError("PostUpdateChannelDisplayNameMessage", "api.channel.post_update_channel_displayname_message_and_forget.create_post.error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -2480,7 +2502,7 @@ func (a *App) postJoinChannelMessage(rctx request.CTX, user *model.User, channel }, } - if _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { + if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { return model.NewAppError("postJoinChannelMessage", "api.channel.post_user_add_remove_message_and_forget.error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -2498,7 +2520,7 @@ func (a *App) postJoinTeamMessage(rctx request.CTX, user *model.User, channel *m }, } - if _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { + if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { return model.NewAppError("postJoinTeamMessage", "api.channel.post_user_add_remove_message_and_forget.error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -2581,7 +2603,7 @@ func (a *App) postLeaveChannelMessage(rctx request.CTX, user *model.User, channe }, } - if _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { + if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { return model.NewAppError("postLeaveChannelMessage", "api.channel.post_user_add_remove_message_and_forget.error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -2610,7 +2632,7 @@ func (a *App) PostAddToChannelMessage(rctx request.CTX, user *model.User, addedU }, } - if _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { + if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { return model.NewAppError("postAddToChannelMessage", "api.channel.post_user_add_remove_message_and_forget.error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -2632,7 +2654,7 @@ func (a *App) postAddToTeamMessage(rctx request.CTX, user *model.User, addedUser }, } - if _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { + if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { return model.NewAppError("postAddToTeamMessage", "api.channel.post_user_add_remove_message_and_forget.error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -2664,7 +2686,7 @@ func (a *App) postRemoveFromChannelMessage(rctx request.CTX, removerUserId strin }, } - if _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { + if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { return model.NewAppError("postRemoveFromChannelMessage", "api.channel.post_user_add_remove_message_and_forget.error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -2868,9 +2890,16 @@ func (a *App) ValidateUserPermissionsOnChannels(rctx request.CTX, userId string, continue } - if channel.Type == model.ChannelTypePrivate && a.HasPermissionToChannel(rctx, userId, channelId, model.PermissionManagePrivateChannelMembers) { - allowedChannelIds = append(allowedChannelIds, channelId) - } else if channel.Type == model.ChannelTypeOpen && a.HasPermissionToChannel(rctx, userId, channelId, model.PermissionManagePublicChannelMembers) { + allowedPrivate := false + if channel.Type == model.ChannelTypePrivate { + allowedPrivate, _ = a.HasPermissionToChannel(rctx, userId, channelId, model.PermissionManagePrivateChannelMembers) + } + allowedPublic := false + if channel.Type == model.ChannelTypeOpen { + allowedPublic, _ = a.HasPermissionToChannel(rctx, userId, channelId, model.PermissionManagePublicChannelMembers) + } + + if allowedPrivate || allowedPublic { allowedChannelIds = append(allowedChannelIds, channelId) } else { rctx.Logger().Info("Invite users to team - no permission to add members to that channel. UserId: " + userId + " ChannelId: " + channelId) @@ -3417,7 +3446,7 @@ func (a *App) postChannelMoveMessage(rctx request.CTX, user *model.User, channel }, } - if _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { + if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { return model.NewAppError("postChannelMoveMessage", "api.team.move_channel.post.error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -3940,7 +3969,7 @@ func (a *App) postMessageForConvertGroupMessageToChannel(rctx request.CTX, chann return appErr } - if _, appErr := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); appErr != nil { + if _, _, appErr := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); appErr != nil { rctx.Logger().Error("Failed to create post for notifying about GM converted to private channel", mlog.Err(appErr)) return model.NewAppError( diff --git a/server/channels/app/channel_test.go b/server/channels/app/channel_test.go index 6653a29eb9f..baf7ba9b3d1 100644 --- a/server/channels/app/channel_test.go +++ b/server/channels/app/channel_test.go @@ -9,7 +9,6 @@ import ( "fmt" "net/http" "slices" - "sort" "strings" "sync" "testing" @@ -265,7 +264,7 @@ func TestMoveChannel(t *testing.T) { ChannelId: channel.Id, Message: "test", } - post, appErr := th.App.CreatePost(th.Context, post, channel, model.CreatePostFlags{}) + post, _, appErr := th.App.CreatePost(th.Context, post, channel, model.CreatePostFlags{}) require.Nil(t, appErr) // Post a reply to the thread @@ -275,7 +274,7 @@ func TestMoveChannel(t *testing.T) { RootId: post.Id, Message: "reply", } - _, appErr = th.App.CreatePost(th.Context, reply, channel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, reply, channel, model.CreatePostFlags{}) require.Nil(t, appErr) // Check that the thread count before move @@ -628,9 +627,7 @@ func TestCreateGroupChannelCreatesChannelMemberHistoryRecord(t *testing.T) { channelMemberHistoryUserIds = append(channelMemberHistoryUserIds, history.UserId) } - sort.Strings(groupUserIds) - sort.Strings(channelMemberHistoryUserIds) - assert.Equal(t, groupUserIds, channelMemberHistoryUserIds) + assert.ElementsMatch(t, groupUserIds, channelMemberHistoryUserIds) } func TestCreateDirectChannelCreatesChannelMemberHistoryRecord(t *testing.T) { @@ -688,6 +685,7 @@ func TestGetDirectChannelCreatesChannelMemberHistoryRecord(t *testing.T) { } func TestAddUserToChannelCreatesChannelMemberHistoryRecord(t *testing.T) { + t.Skip("MM-67041") mainHelper.Parallel(t) th := Setup(t).InitBasic(t).DeleteBots(t) @@ -714,7 +712,7 @@ func TestAddUserToChannelCreatesChannelMemberHistoryRecord(t *testing.T) { assert.Equal(t, channel.Id, history.ChannelId) channelMemberHistoryUserIds = append(channelMemberHistoryUserIds, history.UserId) } - assert.Equal(t, groupUserIds, channelMemberHistoryUserIds) + assert.ElementsMatch(t, groupUserIds, channelMemberHistoryUserIds) } func TestUsersAndPostsCreateActivityInChannel(t *testing.T) { @@ -760,7 +758,7 @@ func TestUsersAndPostsCreateActivityInChannel(t *testing.T) { Message: "root post", UserId: th.BasicUser.Id, } - _, err = th.App.CreatePost(th.Context, post, channel1, model.CreatePostFlags{}) + _, _, err = th.App.CreatePost(th.Context, post, channel1, model.CreatePostFlags{}) require.Nil(t, err, "Failed to create post.") _, err = th.App.AddUserToChannel(th.Context, user, channel2, false) @@ -787,7 +785,7 @@ func TestUsersAndPostsCreateActivityInChannel(t *testing.T) { } err = th.App.RemoveUserFromChannel(th.Context, user4.Id, user4.Id, channel4) require.Nil(t, err, "Failed to create post.") - _, err = th.App.CreatePost(th.Context, post2, channel5, model.CreatePostFlags{}) + _, _, err = th.App.CreatePost(th.Context, post2, channel5, model.CreatePostFlags{}) require.Nil(t, err, "Failed to create post.") _, err = th.App.AddUserToChannel(th.Context, user, channel6, false) require.Nil(t, err, "Failed to add user to channel.") @@ -832,7 +830,7 @@ func TestLeaveDefaultChannel(t *testing.T) { Message: "root post", UserId: th.BasicUser.Id, } - rpost, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + rpost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) reply := &model.Post{ @@ -841,7 +839,7 @@ func TestLeaveDefaultChannel(t *testing.T) { UserId: th.BasicUser.Id, RootId: rpost.Id, } - _, appErr = th.App.CreatePost(th.Context, reply, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + _, _, appErr = th.App.CreatePost(th.Context, reply, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) threads, appErr := th.App.GetThreadsForUser(th.Context, th.BasicUser.Id, townSquare.TeamId, model.GetUserThreadsOpts{}) @@ -870,7 +868,7 @@ func TestLeaveChannel(t *testing.T) { UserId: th.BasicUser.Id, } - rpost, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + rpost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) reply := &model.Post{ @@ -879,7 +877,7 @@ func TestLeaveChannel(t *testing.T) { UserId: th.BasicUser.Id, RootId: rpost.Id, } - _, appErr = th.App.CreatePost(th.Context, reply, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + _, _, appErr = th.App.CreatePost(th.Context, reply, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) return rpost @@ -947,6 +945,7 @@ func TestLeaveLastChannel(t *testing.T) { } func TestAddChannelMemberNoUserRequestor(t *testing.T) { + t.Skip("MM-67037") mainHelper.Parallel(t) th := Setup(t).InitBasic(t) @@ -1605,6 +1604,82 @@ func TestUpdateChannelMemberRolesChangingGuest(t *testing.T) { }) } +func TestUpdateChannelMemberRolesRequireUser(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("empty roles string requires user or guest scheme role", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Tester", Username: "tester" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser, _ := th.App.CreateUser(th.Context, &user) + + _, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, ruser.Id, "") + require.Nil(t, appErr) + + _, appErr = th.App.AddUserToChannel(th.Context, ruser, th.BasicChannel, false) + require.Nil(t, appErr) + + member, appErr := th.App.GetChannelMember(th.Context, th.BasicChannel.Id, ruser.Id) + require.Nil(t, appErr) + require.True(t, member.SchemeUser) + require.False(t, member.SchemeGuest) + + _, appErr = th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, ruser.Id, "") + require.NotNil(t, appErr) + require.Equal(t, "api.channel.update_channel_member_roles.unset_user_scheme.app_error", appErr.Id) + }) + + t.Run("admin role requires user or guest scheme role", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Tester", Username: "tester" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser, _ := th.App.CreateUser(th.Context, &user) + + _, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, ruser.Id, "") + require.Nil(t, appErr) + + _, appErr = th.App.AddUserToChannel(th.Context, ruser, th.BasicChannel, false) + require.Nil(t, appErr) + + _, appErr = th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, ruser.Id, "channel_admin") + require.NotNil(t, appErr) + require.Equal(t, "api.channel.update_channel_member_roles.unset_user_scheme.app_error", appErr.Id) + }) + + t.Run("valid user and admin roles update succeeds", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Tester", Username: "tester" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser, _ := th.App.CreateUser(th.Context, &user) + + _, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, ruser.Id, "") + require.Nil(t, appErr) + + _, appErr = th.App.AddUserToChannel(th.Context, ruser, th.BasicChannel, false) + require.Nil(t, appErr) + + updatedMember, appErr := th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, ruser.Id, "channel_user channel_admin") + require.Nil(t, appErr) + require.True(t, updatedMember.SchemeUser) + require.True(t, updatedMember.SchemeAdmin) + require.False(t, updatedMember.SchemeGuest) + }) + + t.Run("removing admin role while keeping user role succeeds", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Tester", Username: "tester" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser, _ := th.App.CreateUser(th.Context, &user) + + _, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, ruser.Id, "") + require.Nil(t, appErr) + + _, appErr = th.App.AddUserToChannel(th.Context, ruser, th.BasicChannel, false) + require.Nil(t, appErr) + + _, appErr = th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, ruser.Id, "channel_user channel_admin") + require.Nil(t, appErr) + + updatedMember, appErr := th.App.UpdateChannelMemberRoles(th.Context, th.BasicChannel.Id, ruser.Id, "channel_user") + require.Nil(t, appErr) + require.True(t, updatedMember.SchemeUser) + require.False(t, updatedMember.SchemeAdmin) + }) +} + func TestDefaultChannelNames(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) @@ -1776,7 +1851,7 @@ func TestMarkChannelAsUnreadFromPost(t *testing.T) { _, appErr := th.App.AddUserToChannel(th.Context, u2, c2, false) require.Nil(t, appErr) - p4, appErr := th.App.CreatePost(th.Context, &model.Post{ + p4, _, appErr := th.App.CreatePost(th.Context, &model.Post{ UserId: u2.Id, ChannelId: c2.Id, Message: "@" + u1.Username, @@ -1784,7 +1859,7 @@ func TestMarkChannelAsUnreadFromPost(t *testing.T) { require.Nil(t, appErr) th.CreatePost(t, c2) - _, appErr = th.App.CreatePost(th.Context, &model.Post{ + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{ UserId: u2.Id, ChannelId: c2.Id, RootId: p4.Id, @@ -1812,7 +1887,7 @@ func TestMarkChannelAsUnreadFromPost(t *testing.T) { th.CreatePost(t, dc) th.CreatePost(t, dc) - _, appErr := th.App.CreatePost(th.Context, &model.Post{ChannelId: dc.Id, UserId: th.BasicUser.Id, Message: "testReply", RootId: dm1.Id}, dc, model.CreatePostFlags{}) + _, _, appErr := th.App.CreatePost(th.Context, &model.Post{ChannelId: dc.Id, UserId: th.BasicUser.Id, Message: "testReply", RootId: dm1.Id}, dc, model.CreatePostFlags{}) assert.Nil(t, appErr) response, appErr := th.App.MarkChannelAsUnreadFromPost(th.Context, dm1.Id, u2.Id, true) @@ -2460,11 +2535,13 @@ func TestPatchChannelModerationsForChannel(t *testing.T) { _, appErr := th.App.PatchChannelModerationsForChannel(th.Context, channel.DeepCopy(), addCreatePosts) require.Nil(t, appErr) - require.True(t, th.App.SessionHasPermissionToChannel(th.Context, mockSession, channel.Id, model.PermissionCreatePost)) + ok, _ := th.App.SessionHasPermissionToChannel(th.Context, mockSession, channel.Id, model.PermissionCreatePost) + require.True(t, ok) _, appErr = th.App.PatchChannelModerationsForChannel(th.Context, channel.DeepCopy(), removeCreatePosts) require.Nil(t, appErr) - require.False(t, th.App.SessionHasPermissionToChannel(th.Context, mockSession, channel.Id, model.PermissionCreatePost)) + ok, _ = th.App.SessionHasPermissionToChannel(th.Context, mockSession, channel.Id, model.PermissionCreatePost) + require.False(t, ok) }) } @@ -2574,7 +2651,7 @@ func TestViewChannelCollapsedThreadsTurnedOff(t *testing.T) { Message: "root post @" + u1.Username, UserId: u2.Id, } - rpost1, appErr := th.App.CreatePost(th.Context, post1, c1, model.CreatePostFlags{SetOnline: true}) + rpost1, _, appErr := th.App.CreatePost(th.Context, post1, c1, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) // mention the user in a reply post @@ -2584,7 +2661,7 @@ func TestViewChannelCollapsedThreadsTurnedOff(t *testing.T) { UserId: u2.Id, RootId: rpost1.Id, } - _, appErr = th.App.CreatePost(th.Context, post2, c1, model.CreatePostFlags{SetOnline: true}) + _, _, appErr = th.App.CreatePost(th.Context, post2, c1, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) // Check we have unread mention in the thread @@ -2651,19 +2728,19 @@ func TestMarkChannelAsUnreadFromPostCollapsedThreadsTurnedOff(t *testing.T) { // user1: a root post // user2: Another root mention @u1 user1Mention := " @" + th.BasicUser.Username - rootPost1, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "first root mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{}) + rootPost1, _, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "first root mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "hello"}, th.BasicChannel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "hello"}, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) - replyPost1, appErr := th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{}) + replyPost1, _, appErr := th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another reply"}, th.BasicChannel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another reply"}, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost1.Id, UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "a root post"}, th.BasicChannel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "a root post"}, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another root mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "another root mention" + user1Mention}, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) t.Run("Mark reply post as unread", func(t *testing.T) { @@ -2721,17 +2798,17 @@ func TestMarkUnreadCRTOffUpdatesThreads(t *testing.T) { appErr := th.App.PermanentDeleteUser(th.Context, user3) require.Nil(t, appErr) }() - rootPost, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "root post"}, th.BasicChannel, model.CreatePostFlags{}) + rootPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "root post"}, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) - r1, appErr := th.App.CreatePost(th.Context, &model.Post{RootId: rootPost.Id, UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "reply 1"}, th.BasicChannel, model.CreatePostFlags{}) + r1, _, appErr := th.App.CreatePost(th.Context, &model.Post{RootId: rootPost.Id, UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "reply 1"}, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost.Id, UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "reply 2 @" + user3.Username}, th.BasicChannel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost.Id, UserId: th.BasicUser.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "reply 2 @" + user3.Username}, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost.Id, UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "reply 3"}, th.BasicChannel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: rootPost.Id, UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "reply 3"}, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) editedPost := r1.Clone() editedPost.Message += " edited" - _, appErr = th.App.UpdatePost(th.Context, editedPost, &model.UpdatePostOptions{SafeUpdate: false}) + _, _, appErr = th.App.UpdatePost(th.Context, editedPost, &model.UpdatePostOptions{SafeUpdate: false}) require.Nil(t, appErr) th.LinkUserToTeam(t, user3, th.BasicTeam) @@ -3336,7 +3413,7 @@ func TestGetChannelFileCount(t *testing.T) { Message: "This is a test post", UserId: th.BasicUser.Id, } - post, appErr := th.App.CreatePost(th.Context, post, channel, model.CreatePostFlags{}) + post, _, appErr := th.App.CreatePost(th.Context, post, channel, model.CreatePostFlags{}) require.Nil(t, appErr) fileInfo1 := &model.FileInfo{ diff --git a/server/channels/app/command.go b/server/channels/app/command.go index ab0f4e025a1..e7768d1b590 100644 --- a/server/channels/app/command.go +++ b/server/channels/app/command.go @@ -71,7 +71,12 @@ func (a *App) CreateCommandPost(rctx request.CTX, post *model.Post, teamID strin } if response.ResponseType == model.CommandResponseTypeInChannel { - return a.CreatePostMissingChannel(rctx, post, true, true) + // The post is only used for tests, so even if there are membership issues, we won't send the post to the client. + createdPost, _, appErr := a.CreatePostMissingChannel(rctx, post, true, true) + if appErr != nil { + return nil, appErr + } + return createdPost, nil } if (response.ResponseType == "" || response.ResponseType == model.CommandResponseTypeEphemeral) && (response.Text != "" || response.Attachments != nil) { diff --git a/server/channels/app/content_flagging.go b/server/channels/app/content_flagging.go index b231ba5f398..9a8ea111ae2 100644 --- a/server/channels/app/content_flagging.go +++ b/server/channels/app/content_flagging.go @@ -352,7 +352,7 @@ func (a *App) createContentReviewPost(rctx request.CTX, flaggedPostId, teamId, r ChannelId: channel.Id, } post.AddProp(POST_PROP_KEY_FLAGGED_POST_ID, flaggedPostId) - createdPost, appErr := a.CreatePost(rctx, post, channel, model.CreatePostFlags{}) + createdPost, _, appErr := a.CreatePost(rctx, post, channel, model.CreatePostFlags{}) if appErr != nil { rctx.Logger().Error("Failed to create content review post in one of the channels", mlog.Err(appErr), mlog.String("channel_id", channel.Id), mlog.String("team_id", teamId)) continue // Don't stop processing other channels if one fails @@ -1118,7 +1118,12 @@ func (a *App) postContentReviewBotMessage(rctx request.CTX, flaggedPost *model.P ChannelId: dmChannel.Id, } - return a.CreatePost(rctx, post, dmChannel, model.CreatePostFlags{}) + // We can ignore the membership since the post itself is does not have a permalink + createdPost, _, appErr := a.CreatePost(rctx, post, dmChannel, model.CreatePostFlags{}) + if appErr != nil { + return nil, appErr + } + return createdPost, nil } func (a *App) postMessageToReporter(rctx request.CTX, contentFlaggingGroupId string, flaggedPost *model.Post, messageTemplate string) (*model.Post, *model.AppError) { @@ -1172,7 +1177,8 @@ func (a *App) postReviewerMessage(rctx request.CTX, message, contentFlaggingGrou RootId: postId, } - createdPost, appErr := a.CreatePost(rctx, post, channel, model.CreatePostFlags{}) + // We can ignore the membership since the post itself is does not have a permalink + createdPost, _, appErr := a.CreatePost(rctx, post, channel, model.CreatePostFlags{}) if appErr != nil { rctx.Logger().Error("Failed to create assign reviewer post in one of the channels", mlog.Err(appErr), mlog.String("channel_id", channel.Id), mlog.String("post_id", postId)) continue @@ -1250,7 +1256,7 @@ func (a *App) sendFlagPostNotification(rctx request.CTX, flaggedPost *model.Post ChannelId: dmChannel.Id, } - _, appErr = a.CreatePost(rctx, post, dmChannel, model.CreatePostFlags{}) + _, _, appErr = a.CreatePost(rctx, post, dmChannel, model.CreatePostFlags{}) return appErr } diff --git a/server/channels/app/content_flagging_test.go b/server/channels/app/content_flagging_test.go index 6b9c4c1a5af..0e60e4fb184 100644 --- a/server/channels/app/content_flagging_test.go +++ b/server/channels/app/content_flagging_test.go @@ -645,7 +645,7 @@ func TestGetContentReviewChannels(t *testing.T) { require.Nil(t, appErr) - _, appErr = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, teamAdmin.Id, model.TeamAdminRoleId) + _, appErr = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, teamAdmin.Id, model.TeamUserRoleId+" "+model.TeamAdminRoleId) require.Nil(t, appErr) contentReviewBot, appErr := th.App.getContentReviewBot(th.Context) @@ -844,7 +844,7 @@ func TestGetReviewersForTeam(t *testing.T) { _, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, teamAdmin.Id, "") require.Nil(t, appErr) - _, appErr = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, teamAdmin.Id, model.TeamAdminRoleId) + _, appErr = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, teamAdmin.Id, model.TeamUserRoleId+" "+model.TeamAdminRoleId) require.Nil(t, appErr) reviewers, appErr := th.App.getReviewersForTeam(th.BasicTeam.Id, true) @@ -1424,7 +1424,7 @@ func TestSearchReviewers(t *testing.T) { _, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, teamAdmin.Id, "") require.Nil(t, appErr) - _, appErr = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, teamAdmin.Id, model.TeamAdminRoleId) + _, appErr = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, teamAdmin.Id, model.TeamUserRoleId+" "+model.TeamAdminRoleId) require.Nil(t, appErr) // Search for team admin @@ -1461,7 +1461,7 @@ func TestSearchReviewers(t *testing.T) { _, _, appErr = th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, teamAdmin.Id, "") require.Nil(t, appErr) - _, appErr = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, teamAdmin.Id, model.TeamAdminRoleId) + _, appErr = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, teamAdmin.Id, model.TeamUserRoleId+" "+model.TeamAdminRoleId) require.Nil(t, appErr) // Search with empty term should return all reviewers @@ -2446,7 +2446,7 @@ func TestPermanentDeleteFlaggedPost(t *testing.T) { // Update post to include file IDs post.FileIds = []string{fileInfo1.Id, fileInfo2.Id} - _, appErr := th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{}) + _, _, appErr := th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{}) require.Nil(t, appErr) // Flag the post @@ -2487,7 +2487,7 @@ func TestPermanentDeleteFlaggedPost(t *testing.T) { editedPost.Message = "Edited message" editedPost.EditAt = model.GetMillis() - _, appErr := th.App.UpdatePost(th.Context, editedPost, &model.UpdatePostOptions{}) + _, _, appErr := th.App.UpdatePost(th.Context, editedPost, &model.UpdatePostOptions{}) require.Nil(t, appErr) // Flag the post diff --git a/server/channels/app/export_test.go b/server/channels/app/export_test.go index 383f6278649..8a4eb70af10 100644 --- a/server/channels/app/export_test.go +++ b/server/channels/app/export_test.go @@ -541,7 +541,7 @@ func TestExportDMandGMPost(t *testing.T) { Message: "aa" + model.NewId() + "a", UserId: th1.BasicUser.Id, } - _, appErr := th1.App.CreatePost(th1.Context, p1, dmChannel, model.CreatePostFlags{SetOnline: true}) + _, _, appErr := th1.App.CreatePost(th1.Context, p1, dmChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) p2 := &model.Post{ @@ -549,7 +549,7 @@ func TestExportDMandGMPost(t *testing.T) { Message: "bb" + model.NewId() + "a", UserId: th1.BasicUser.Id, } - _, appErr = th1.App.CreatePost(th1.Context, p2, dmChannel, model.CreatePostFlags{SetOnline: true}) + _, _, appErr = th1.App.CreatePost(th1.Context, p2, dmChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) // GM posts @@ -558,7 +558,7 @@ func TestExportDMandGMPost(t *testing.T) { Message: "cc" + model.NewId() + "a", UserId: th1.BasicUser.Id, } - _, appErr = th1.App.CreatePost(th1.Context, p3, gmChannel, model.CreatePostFlags{SetOnline: true}) + _, _, appErr = th1.App.CreatePost(th1.Context, p3, gmChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) p4 := &model.Post{ @@ -566,7 +566,7 @@ func TestExportDMandGMPost(t *testing.T) { Message: "dd" + model.NewId() + "a", UserId: th1.BasicUser.Id, } - _, appErr = th1.App.CreatePost(th1.Context, p4, gmChannel, model.CreatePostFlags{SetOnline: true}) + _, _, appErr = th1.App.CreatePost(th1.Context, p4, gmChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) posts, err := th1.App.Srv().Store().Post().GetDirectPostParentsForExportAfter(1000, "0000000", false) @@ -628,7 +628,7 @@ func TestExportPostWithProps(t *testing.T) { }, UserId: th1.BasicUser.Id, } - _, appErr := th1.App.CreatePost(th1.Context, p1, dmChannel, model.CreatePostFlags{SetOnline: true}) + _, _, appErr := th1.App.CreatePost(th1.Context, p1, dmChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) p2 := &model.Post{ @@ -639,7 +639,7 @@ func TestExportPostWithProps(t *testing.T) { }, UserId: th1.BasicUser.Id, } - _, appErr = th1.App.CreatePost(th1.Context, p2, gmChannel, model.CreatePostFlags{SetOnline: true}) + _, _, appErr = th1.App.CreatePost(th1.Context, p2, gmChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) posts, err := th1.App.Srv().Store().Post().GetDirectPostParentsForExportAfter(1000, "0000000", false) @@ -1047,7 +1047,7 @@ func TestBuildPostReplies(t *testing.T) { fileIDs = append(fileIDs, info.Id) } - post, err := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, RootId: rootID, FileIds: fileIDs}, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + post, _, err := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, RootId: rootID, FileIds: fileIDs}, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) return post @@ -1597,7 +1597,7 @@ func TestExportDeactivatedUserDMs(t *testing.T) { Message: initialMessage, UserId: th1.BasicUser.Id, } - initialPostCreated, appErr := th1.App.CreatePost(th1.Context, initialPost, dmChannel, model.CreatePostFlags{SetOnline: true}) + initialPostCreated, _, appErr := th1.App.CreatePost(th1.Context, initialPost, dmChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) // 2. Have user2 reply with TWO types of replies: @@ -1610,7 +1610,7 @@ func TestExportDeactivatedUserDMs(t *testing.T) { UserId: user2.Id, RootId: initialPostCreated.Id, // This makes it a threaded reply } - _, appErr = th1.App.CreatePost(th1.Context, threadedReply, dmChannel, model.CreatePostFlags{SetOnline: true}) + _, _, appErr = th1.App.CreatePost(th1.Context, threadedReply, dmChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) // 2b. User2 sends a standalone reply (NOT in a thread) @@ -1621,7 +1621,7 @@ func TestExportDeactivatedUserDMs(t *testing.T) { UserId: user2.Id, // No RootId, making it a standalone message, not a thread reply } - _, appErr = th1.App.CreatePost(th1.Context, nonThreadedReply, dmChannel, model.CreatePostFlags{SetOnline: true}) + _, _, appErr = th1.App.CreatePost(th1.Context, nonThreadedReply, dmChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) // 3. Now deactivate user2 diff --git a/server/channels/app/extract_plugin_tar_test.go b/server/channels/app/extract_plugin_tar_test.go index a4e76c8f860..e04605908b4 100644 --- a/server/channels/app/extract_plugin_tar_test.go +++ b/server/channels/app/extract_plugin_tar_test.go @@ -10,7 +10,6 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "testing" @@ -29,9 +28,7 @@ func assertDirectoryContents(t *testing.T, dir string, expectedFiles []string) { }) require.NoError(t, err) - sort.Strings(files) - sort.Strings(expectedFiles) - assert.Equal(t, expectedFiles, files) + assert.ElementsMatch(t, expectedFiles, files) } func TestExtractTarGz(t *testing.T) { diff --git a/server/channels/app/file.go b/server/channels/app/file.go index ca20a341bd6..db07d36b170 100644 --- a/server/channels/app/file.go +++ b/server/channels/app/file.go @@ -22,6 +22,9 @@ import ( "sync" "time" + "maps" + "slices" + "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" "github.com/mattermost/mattermost/server/public/shared/mlog" @@ -1438,11 +1441,11 @@ func populateZipfile(w *zip.Writer, fileDatas []model.FileData) error { return nil } -func (a *App) SearchFilesInTeamForUser(rctx request.CTX, terms string, userId string, teamId string, isOrSearch bool, includeDeletedChannels bool, timeZoneOffset int, page, perPage int) (*model.FileInfoList, *model.AppError) { +func (a *App) SearchFilesInTeamForUser(rctx request.CTX, terms string, userId string, teamId string, isOrSearch bool, includeDeletedChannels bool, timeZoneOffset int, page, perPage int) (*model.FileInfoList, bool, *model.AppError) { paramsList := model.ParseSearchParams(strings.TrimSpace(terms), timeZoneOffset) if !*a.Config().ServiceSettings.EnableFileSearch { - return nil, model.NewAppError("SearchFilesInTeamForUser", "store.sql_file_info.search.disabled", nil, fmt.Sprintf("teamId=%v userId=%v", teamId, userId), http.StatusNotImplemented) + return nil, false, model.NewAppError("SearchFilesInTeamForUser", "store.sql_file_info.search.disabled", nil, fmt.Sprintf("teamId=%v userId=%v", teamId, userId), http.StatusNotImplemented) } finalParamsList := []*model.SearchParams{} @@ -1466,7 +1469,7 @@ func (a *App) SearchFilesInTeamForUser(rctx request.CTX, terms string, userId st // If the processed search params are empty, return empty search results. if len(finalParamsList) == 0 { - return model.NewFileInfoList(), nil + return model.NewFileInfoList(), true, nil } fileInfoSearchResults, nErr := a.Srv().Store().FileInfo().Search(rctx, finalParamsList, userId, teamId, page, perPage) @@ -1474,13 +1477,81 @@ func (a *App) SearchFilesInTeamForUser(rctx request.CTX, terms string, userId st var appErr *model.AppError switch { case errors.As(nErr, &appErr): - return nil, appErr + return nil, false, appErr default: - return nil, model.NewAppError("SearchFilesInTeamForUser", "app.post.search.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + return nil, false, model.NewAppError("SearchFilesInTeamForUser", "app.post.search.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } } - return fileInfoSearchResults, a.filterInaccessibleFiles(fileInfoSearchResults, filterFileOptions{assumeSortedCreatedAt: true}) + if appErr := a.filterInaccessibleFiles(fileInfoSearchResults, filterFileOptions{assumeSortedCreatedAt: true}); appErr != nil { + return nil, false, appErr + } + + allFilesHaveMembership, appErr := a.FilterFilesByChannelPermissions(rctx, fileInfoSearchResults, userId) + if appErr != nil { + return nil, false, appErr + } + + return fileInfoSearchResults, allFilesHaveMembership, nil +} + +func (a *App) FilterFilesByChannelPermissions(rctx request.CTX, fileList *model.FileInfoList, userID string) (bool, *model.AppError) { + if fileList == nil || fileList.FileInfos == nil || len(fileList.FileInfos) == 0 { + return true, nil // On an empty file list, we consider all files as having membership + } + + channels := make(map[string]*model.Channel) + for _, fileInfo := range fileList.FileInfos { + if fileInfo.ChannelId != "" { + channels[fileInfo.ChannelId] = nil + } + } + + if len(channels) > 0 { + channelIDs := slices.Collect(maps.Keys(channels)) + channelList, err := a.GetChannels(rctx, channelIDs) + if err != nil && err.StatusCode != http.StatusNotFound { + return false, err + } + for _, channel := range channelList { + channels[channel.Id] = channel + } + } + + channelReadPermission := make(map[string]bool) + filteredFiles := make(map[string]*model.FileInfo) + filteredOrder := []string{} + allFilesHaveMembership := true + + for _, fileID := range fileList.Order { + fileInfo, ok := fileList.FileInfos[fileID] + if !ok { + continue + } + + if _, ok := channelReadPermission[fileInfo.ChannelId]; !ok { + channel := channels[fileInfo.ChannelId] + allowed := false + isMember := true + if channel != nil { + allowed, isMember = a.HasPermissionToReadChannel(rctx, userID, channel) + } + channelReadPermission[fileInfo.ChannelId] = allowed + if allowed { + allFilesHaveMembership = allFilesHaveMembership && isMember + } + } + + if channelReadPermission[fileInfo.ChannelId] { + filteredFiles[fileID] = fileInfo + filteredOrder = append(filteredOrder, fileID) + } + } + + fileList.FileInfos = filteredFiles + fileList.Order = filteredOrder + + return allFilesHaveMembership, nil } func (a *App) ExtractContentFromFileInfo(rctx request.CTX, fileInfo *model.FileInfo) error { diff --git a/server/channels/app/file_test.go b/server/channels/app/file_test.go index 89a0e502d42..0f52a8911c9 100644 --- a/server/channels/app/file_test.go +++ b/server/channels/app/file_test.go @@ -291,13 +291,13 @@ func TestMigrateFilenamesToFileInfos(t *testing.T) { fpath := fmt.Sprintf("/teams/%v/channels/%v/users/%v/%v/test.png", th.BasicTeam.Id, th.BasicChannel.Id, th.BasicUser.Id, fileID) _, err := th.App.WriteFile(file, fpath) require.Nil(t, err) - rpost, err := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Filenames: []string{fmt.Sprintf("/%v/%v/%v/test.png", th.BasicChannel.Id, th.BasicUser.Id, fileID)}}, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + rpost, _, err := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Filenames: []string{fmt.Sprintf("/%v/%v/%v/test.png", th.BasicChannel.Id, th.BasicUser.Id, fileID)}}, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) infos = th.App.MigrateFilenamesToFileInfos(th.Context, rpost) assert.Equal(t, 1, len(infos)) - rpost, err = th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Filenames: []string{fmt.Sprintf("/%v/%v/%v/../../test.png", th.BasicChannel.Id, th.BasicUser.Id, fileID)}}, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + rpost, _, err = th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Filenames: []string{fmt.Sprintf("/%v/%v/%v/../../test.png", th.BasicChannel.Id, th.BasicUser.Id, fileID)}}, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) infos = th.App.MigrateFilenamesToFileInfos(th.Context, rpost) @@ -511,7 +511,7 @@ func TestSearchFilesInTeamForUser(t *testing.T) { page := 0 - results, err := th.App.SearchFilesInTeamForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) + results, allFilesHaveMembership, err := th.App.SearchFilesInTeamForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) require.Nil(t, err) require.NotNil(t, results) @@ -524,6 +524,7 @@ func TestSearchFilesInTeamForUser(t *testing.T) { fileInfos[1].Id, fileInfos[0].Id, }, results.Order) + require.True(t, allFilesHaveMembership) }) t.Run("should not return later pages of fileInfos from database", func(t *testing.T) { @@ -531,11 +532,12 @@ func TestSearchFilesInTeamForUser(t *testing.T) { page := 1 - results, err := th.App.SearchFilesInTeamForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) + results, allFilesHaveMembership, err := th.App.SearchFilesInTeamForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) require.Nil(t, err) require.NotNil(t, results) assert.Equal(t, []string{}, results.Order) + require.True(t, allFilesHaveMembership) }) t.Run("should return first page of fileInfos from ElasticSearch", func(t *testing.T) { @@ -560,11 +562,12 @@ func TestSearchFilesInTeamForUser(t *testing.T) { th.App.Srv().Platform().SearchEngine.ElasticsearchEngine = nil }() - results, err := th.App.SearchFilesInTeamForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) + results, allFilesHaveMembership, err := th.App.SearchFilesInTeamForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) require.Nil(t, err) require.NotNil(t, results) assert.Equal(t, resultsPage, results.Order) + require.True(t, allFilesHaveMembership) es.AssertExpectations(t) }) @@ -587,11 +590,12 @@ func TestSearchFilesInTeamForUser(t *testing.T) { th.App.Srv().Platform().SearchEngine.ElasticsearchEngine = nil }() - results, err := th.App.SearchFilesInTeamForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) + results, allFilesHaveMembership, err := th.App.SearchFilesInTeamForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) require.Nil(t, err) require.NotNil(t, results) assert.Equal(t, resultsPage, results.Order) + require.True(t, allFilesHaveMembership) es.AssertExpectations(t) }) @@ -611,7 +615,7 @@ func TestSearchFilesInTeamForUser(t *testing.T) { th.App.Srv().Platform().SearchEngine.ElasticsearchEngine = nil }() - results, err := th.App.SearchFilesInTeamForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) + results, allFilesHaveMembership, err := th.App.SearchFilesInTeamForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) require.Nil(t, err) require.NotNil(t, results) @@ -624,6 +628,7 @@ func TestSearchFilesInTeamForUser(t *testing.T) { fileInfos[1].Id, fileInfos[0].Id, }, results.Order) + require.True(t, allFilesHaveMembership) es.AssertExpectations(t) }) @@ -643,10 +648,11 @@ func TestSearchFilesInTeamForUser(t *testing.T) { th.App.Srv().Platform().SearchEngine.ElasticsearchEngine = nil }() - results, err := th.App.SearchFilesInTeamForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) + results, allFilesHaveMembership, err := th.App.SearchFilesInTeamForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) require.Nil(t, err) assert.Equal(t, []string{}, results.Order) + require.True(t, allFilesHaveMembership) es.AssertExpectations(t) }) } @@ -769,16 +775,18 @@ func TestSetFileSearchableContent(t *testing.T) { }) require.NoError(t, err) - result, appErr := th.App.SearchFilesInTeamForUser(th.Context, "searchable", th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, 0, 60) + result, allFilesHaveMembership, appErr := th.App.SearchFilesInTeamForUser(th.Context, "searchable", th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, 0, 60) require.Nil(t, appErr) assert.Equal(t, 0, len(result.Order)) + require.True(t, allFilesHaveMembership) appErr = th.App.SetFileSearchableContent(th.Context, fileInfo.Id, "searchable") require.Nil(t, appErr) - result, appErr = th.App.SearchFilesInTeamForUser(th.Context, "searchable", th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, 0, 60) + result, allFilesHaveMembership, appErr = th.App.SearchFilesInTeamForUser(th.Context, "searchable", th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, 0, 60) require.Nil(t, appErr) assert.Equal(t, 1, len(result.Order)) + require.True(t, allFilesHaveMembership) } func TestPermanentDeleteFilesByPost(t *testing.T) { @@ -805,7 +813,7 @@ func TestPermanentDeleteFilesByPost(t *testing.T) { FileIds: []string{info1.Id}, } - post, err = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + post, _, err = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) assert.Nil(t, err) err = th.App.PermanentDeleteFilesByPost(th.Context, post.Id) @@ -829,10 +837,155 @@ func TestPermanentDeleteFilesByPost(t *testing.T) { CreateAt: 0, } - post, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + post, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) assert.Nil(t, err) err = th.App.PermanentDeleteFilesByPost(th.Context, post.Id) assert.Nil(t, err) }) } + +func TestFilterFilesByChannelPermissions(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.GuestAccountsSettings.Enable = true + }) + + guestUser := th.CreateGuest(t) + _, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, guestUser.Id, "") + require.Nil(t, appErr) + + privateChannel := th.CreatePrivateChannel(t, th.BasicTeam) + + _, appErr = th.App.AddUserToChannel(th.Context, guestUser, privateChannel, false) + require.Nil(t, appErr) + _, appErr = th.App.AddUserToChannel(th.Context, guestUser, th.BasicChannel, false) + require.Nil(t, appErr) + + post1 := th.CreatePost(t, th.BasicChannel) + post2 := th.CreatePost(t, privateChannel) + post3 := th.CreatePost(t, th.BasicChannel) + + fileInfo1 := th.CreateFileInfo(t, th.BasicUser.Id, post1.Id, th.BasicChannel.Id) + fileInfo2 := th.CreateFileInfo(t, th.BasicUser.Id, post2.Id, privateChannel.Id) + fileInfo3 := th.CreateFileInfo(t, th.BasicUser.Id, post3.Id, th.BasicChannel.Id) + + t.Run("should filter files when user has read_channel_content permission", func(t *testing.T) { + fileList := model.NewFileInfoList() + fileList.FileInfos[fileInfo1.Id] = fileInfo1 + fileList.FileInfos[fileInfo2.Id] = fileInfo2 + fileList.FileInfos[fileInfo3.Id] = fileInfo3 + fileList.Order = []string{fileInfo1.Id, fileInfo2.Id, fileInfo3.Id} + + // BasicUser should have access to all files + allFilesHaveMembership, appErr := th.App.FilterFilesByChannelPermissions(th.Context, fileList, th.BasicUser.Id) + require.Nil(t, appErr) + require.Len(t, fileList.FileInfos, 3) + require.Len(t, fileList.Order, 3) + require.True(t, allFilesHaveMembership) + }) + + t.Run("should filter files when guest has read_channel_content permission", func(t *testing.T) { + fileList := model.NewFileInfoList() + fileList.FileInfos[fileInfo1.Id] = fileInfo1 + fileList.FileInfos[fileInfo2.Id] = fileInfo2 + fileList.FileInfos[fileInfo3.Id] = fileInfo3 + fileList.Order = []string{fileInfo1.Id, fileInfo2.Id, fileInfo3.Id} + + allFilesHaveMembership, appErr := th.App.FilterFilesByChannelPermissions(th.Context, fileList, guestUser.Id) + require.Nil(t, appErr) + require.Len(t, fileList.FileInfos, 3) + require.Len(t, fileList.Order, 3) + require.True(t, allFilesHaveMembership) + }) + + t.Run("should filter files when guest does not have read_channel_content permission", func(t *testing.T) { + channelGuestRole, appErr := th.App.GetRoleByName(th.Context, model.ChannelGuestRoleId) + require.Nil(t, appErr) + + originalPermissions := make([]string, len(channelGuestRole.Permissions)) + copy(originalPermissions, channelGuestRole.Permissions) + + newPermissions := []string{} + for _, perm := range channelGuestRole.Permissions { + if perm != model.PermissionReadChannelContent.Id && perm != model.PermissionReadChannel.Id { + newPermissions = append(newPermissions, perm) + } + } + + _, appErr = th.App.PatchRole(channelGuestRole, &model.RolePatch{ + Permissions: &newPermissions, + }) + require.Nil(t, appErr) + + defer func() { + _, err := th.App.PatchRole(channelGuestRole, &model.RolePatch{ + Permissions: &originalPermissions, + }) + require.Nil(t, err) + }() + + fileList := model.NewFileInfoList() + fileList.FileInfos[fileInfo1.Id] = fileInfo1 + fileList.FileInfos[fileInfo2.Id] = fileInfo2 + fileList.FileInfos[fileInfo3.Id] = fileInfo3 + fileList.Order = []string{fileInfo1.Id, fileInfo2.Id, fileInfo3.Id} + + allFilesHaveMembership, appErr := th.App.FilterFilesByChannelPermissions(th.Context, fileList, guestUser.Id) + require.Nil(t, appErr) + require.Len(t, fileList.FileInfos, 0) + require.Len(t, fileList.Order, 0) + require.True(t, allFilesHaveMembership) + }) + + t.Run("should handle empty file list", func(t *testing.T) { + fileList := model.NewFileInfoList() + allFilesHaveMembership, appErr := th.App.FilterFilesByChannelPermissions(th.Context, fileList, th.BasicUser.Id) + require.Nil(t, appErr) + require.Len(t, fileList.FileInfos, 0) + require.Len(t, fileList.Order, 0) + require.True(t, allFilesHaveMembership) + }) + + t.Run("should handle nil file list", func(t *testing.T) { + allFilesHaveMembership, appErr := th.App.FilterFilesByChannelPermissions(th.Context, nil, th.BasicUser.Id) + require.True(t, allFilesHaveMembership) + require.Nil(t, appErr) + }) + + t.Run("should handle files with empty channel IDs", func(t *testing.T) { + fileList := model.NewFileInfoList() + fileWithoutChannel := &model.FileInfo{ + Id: model.NewId(), + ChannelId: "", + Name: "test.txt", + } + fileList.FileInfos[fileWithoutChannel.Id] = fileWithoutChannel + fileList.Order = []string{fileWithoutChannel.Id} + + allFilesHaveMembership, appErr := th.App.FilterFilesByChannelPermissions(th.Context, fileList, th.BasicUser.Id) + require.Nil(t, appErr) + require.Len(t, fileList.FileInfos, 0) + require.Len(t, fileList.Order, 0) + require.True(t, allFilesHaveMembership) + }) + + t.Run("should handle files from non-existent channels", func(t *testing.T) { + fileList := model.NewFileInfoList() + fileWithInvalidChannel := &model.FileInfo{ + Id: model.NewId(), + ChannelId: model.NewId(), + Name: "test.txt", + } + fileList.FileInfos[fileWithInvalidChannel.Id] = fileWithInvalidChannel + fileList.Order = []string{fileWithInvalidChannel.Id} + + allFilesHaveMembership, appErr := th.App.FilterFilesByChannelPermissions(th.Context, fileList, th.BasicUser.Id) + require.Nil(t, appErr) + require.Len(t, fileList.FileInfos, 0) + require.Len(t, fileList.Order, 0) + require.True(t, allFilesHaveMembership) + }) +} diff --git a/server/channels/app/helper_test.go b/server/channels/app/helper_test.go index 6094b85248d..f0ec945d4a2 100644 --- a/server/channels/app/helper_test.go +++ b/server/channels/app/helper_test.go @@ -488,7 +488,7 @@ func (th *TestHelper) CreatePost(tb testing.TB, channel *model.Channel, postOpti option(post) } - post, err := th.App.CreatePost(th.Context, post, channel, model.CreatePostFlags{SetOnline: true}) + post, _, err := th.App.CreatePost(th.Context, post, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(tb, err) return post } @@ -501,7 +501,7 @@ func (th *TestHelper) CreateMessagePost(tb testing.TB, channel *model.Channel, m CreateAt: model.GetMillis() - 10000, } - post, err := th.App.CreatePost(th.Context, post, channel, model.CreatePostFlags{SetOnline: true}) + post, _, err := th.App.CreatePost(th.Context, post, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(tb, err) return post } @@ -518,7 +518,7 @@ func (th *TestHelper) CreatePostReply(tb testing.TB, root *model.Post) *model.Po ch, err := th.App.GetChannel(th.Context, root.ChannelId) require.Nil(tb, err) - post, err = th.App.CreatePost(th.Context, post, ch, model.CreatePostFlags{SetOnline: true}) + post, _, err = th.App.CreatePost(th.Context, post, ch, model.CreatePostFlags{SetOnline: true}) require.Nil(tb, err) return post } @@ -775,7 +775,7 @@ func (th *TestHelper) PostPatch(tb testing.TB, post *model.Post, message string, optionFunc(postPatch) } - updatedPost, appErr := th.App.PatchPost(th.Context, post.Id, postPatch, nil) + updatedPost, _, appErr := th.App.PatchPost(th.Context, post.Id, postPatch, nil) require.Nil(tb, appErr) return updatedPost diff --git a/server/channels/app/imaging/decode.go b/server/channels/app/imaging/decode.go index 756ee1f8eef..87bf4d37a3c 100644 --- a/server/channels/app/imaging/decode.go +++ b/server/channels/app/imaging/decode.go @@ -13,7 +13,6 @@ import ( "io" "sync" - _ "github.com/oov/psd" _ "golang.org/x/image/bmp" _ "golang.org/x/image/tiff" _ "golang.org/x/image/webp" diff --git a/server/channels/app/imaging/decode_test.go b/server/channels/app/imaging/decode_test.go index f7efa07bb82..5328f886e77 100644 --- a/server/channels/app/imaging/decode_test.go +++ b/server/channels/app/imaging/decode_test.go @@ -115,6 +115,20 @@ func TestDecoderDecode(t *testing.T) { }) } +func TestPSDNotSupported(t *testing.T) { + // MM-67077: PSD preview support was removed due to memory vulnerability in oov/psd package + d, err := NewDecoder(DecoderOptions{}) + require.NotNil(t, d) + require.NoError(t, err) + + // PSD file header magic bytes: "8BPS" followed by version (0x0001 for PSD) + psdHeader := []byte("8BPS\x00\x01") + _, _, err = d.Decode(bytes.NewReader(psdHeader)) + + require.Error(t, err) + require.Contains(t, err.Error(), "unknown format") +} + func TestDecoderDecodeMemBounded(t *testing.T) { t.Run("concurrency bounded", func(t *testing.T) { d, err := NewDecoder(DecoderOptions{ diff --git a/server/channels/app/import_functions.go b/server/channels/app/import_functions.go index 70cf6d785e3..4db0bad10c0 100644 --- a/server/channels/app/import_functions.go +++ b/server/channels/app/import_functions.go @@ -1116,7 +1116,8 @@ func (a *App) importUserTeams(rctx request.CTX, user *model.User, data *[]import for _, member := range append(newMembers, oldMembers...) { if member.ExplicitRoles != rolesByTeamID[member.TeamId] { - if _, appErr = a.UpdateTeamMemberRoles(rctx, member.TeamId, user.Id, rolesByTeamID[member.TeamId]); appErr != nil { + // Bulk import uses internal function to support two-phase role updates. + if _, appErr = a.updateTeamMemberRolesInternal(rctx, member.TeamId, user.Id, rolesByTeamID[member.TeamId], true); appErr != nil { return appErr } } @@ -1308,7 +1309,8 @@ func (a *App) importUserChannels(rctx request.CTX, user *model.User, team *model for _, member := range append(newMembers, oldMembers...) { if member.ExplicitRoles != rolesByChannelId[member.ChannelId] { - if _, err = a.UpdateChannelMemberRoles(rctx, member.ChannelId, user.Id, rolesByChannelId[member.ChannelId]); err != nil { + // Bulk import uses internal function to support two-phase role updates. + if _, err = a.updateChannelMemberRolesInternal(rctx, member.ChannelId, user.Id, rolesByChannelId[member.ChannelId], true); err != nil { return err } } diff --git a/server/channels/app/integration_action.go b/server/channels/app/integration_action.go index 5b509261cbf..b72fa94a636 100644 --- a/server/channels/app/integration_action.go +++ b/server/channels/app/integration_action.go @@ -280,7 +280,7 @@ func (a *App) DoPostActionWithCookie(rctx request.CTX, postID, actionId, userID, response.Update.IsPinned = originalIsPinned response.Update.HasReactions = originalHasReactions - if _, appErr = a.UpdatePost(rctx, response.Update, &model.UpdatePostOptions{SafeUpdate: false}); appErr != nil { + if _, _, appErr = a.UpdatePost(rctx, response.Update, &model.UpdatePostOptions{SafeUpdate: false}); appErr != nil { return "", appErr } } diff --git a/server/channels/app/integration_action_test.go b/server/channels/app/integration_action_test.go index e22dedf2972..a55b6876e97 100644 --- a/server/channels/app/integration_action_test.go +++ b/server/channels/app/integration_action_test.go @@ -60,7 +60,7 @@ func TestPostActionInvalidURL(t *testing.T) { }, } - post, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) + post, _, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) require.Nil(t, err) attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) require.True(t, ok) @@ -113,7 +113,7 @@ func TestPostActionEmptyResponse(t *testing.T) { }, } - post, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) + post, _, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) require.Nil(t, err) attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) @@ -157,7 +157,7 @@ func TestPostActionEmptyResponse(t *testing.T) { }, } - post, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) + post, _, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) require.Nil(t, err) attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) @@ -267,7 +267,7 @@ func TestPostAction(t *testing.T) { }, } - post, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) + post, _, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) require.Nil(t, err) attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) @@ -304,7 +304,7 @@ func TestPostAction(t *testing.T) { }, } - post2, err := th.App.CreatePostAsUser(th.Context, &menuPost, "", true) + post2, _, err := th.App.CreatePostAsUser(th.Context, &menuPost, "", true) require.Nil(t, err) attachments2, ok := post2.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) @@ -362,7 +362,7 @@ func TestPostAction(t *testing.T) { }, } - postplugin, err := th.App.CreatePostAsUser(th.Context, &interactivePostPlugin, "", true) + postplugin, _, err := th.App.CreatePostAsUser(th.Context, &interactivePostPlugin, "", true) require.Nil(t, err) attachmentsPlugin, ok := postplugin.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) @@ -410,7 +410,7 @@ func TestPostAction(t *testing.T) { }, } - postSiteURL, err := th.App.CreatePostAsUser(th.Context, &interactivePostSiteURL, "", true) + postSiteURL, _, err := th.App.CreatePostAsUser(th.Context, &interactivePostSiteURL, "", true) require.Nil(t, err) attachmentsSiteURL, ok := postSiteURL.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) @@ -452,7 +452,7 @@ func TestPostAction(t *testing.T) { }, } - postSubpath, err := th.App.CreatePostAsUser(th.Context, &interactivePostSubpath, "", true) + postSubpath, _, err := th.App.CreatePostAsUser(th.Context, &interactivePostSubpath, "", true) require.Nil(t, err) attachmentsSubpath, ok := postSubpath.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) @@ -527,7 +527,7 @@ func TestPostActionProps(t *testing.T) { }, } - post, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) + post, _, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) require.Nil(t, err) attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) require.True(t, ok) @@ -711,7 +711,7 @@ func TestPostActionRelativeURL(t *testing.T) { }, } - post, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) + post, _, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) require.Nil(t, err) attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) require.True(t, ok) @@ -751,7 +751,7 @@ func TestPostActionRelativeURL(t *testing.T) { }, } - post, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) + post, _, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) require.Nil(t, err) attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) require.True(t, ok) @@ -791,7 +791,7 @@ func TestPostActionRelativeURL(t *testing.T) { }, } - post, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) + post, _, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) require.Nil(t, err) attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) require.True(t, ok) @@ -831,7 +831,7 @@ func TestPostActionRelativeURL(t *testing.T) { }, } - post, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) + post, _, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) require.Nil(t, err) attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) require.True(t, ok) @@ -871,7 +871,7 @@ func TestPostActionRelativeURL(t *testing.T) { }, } - post, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) + post, _, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) require.Nil(t, err) attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) require.True(t, ok) @@ -948,7 +948,7 @@ func TestPostActionRelativePluginURL(t *testing.T) { }, } - post, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) + post, _, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) require.Nil(t, err) attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) require.True(t, ok) @@ -988,7 +988,7 @@ func TestPostActionRelativePluginURL(t *testing.T) { }, } - post, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) + post, _, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) require.Nil(t, err) attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) require.True(t, ok) @@ -1028,7 +1028,7 @@ func TestPostActionRelativePluginURL(t *testing.T) { }, } - post, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) + post, _, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) require.Nil(t, err) attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) require.True(t, ok) @@ -1068,7 +1068,7 @@ func TestPostActionRelativePluginURL(t *testing.T) { }, } - post, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) + post, _, err := th.App.CreatePostAsUser(th.Context, &interactivePost, "", true) require.Nil(t, err) attachments, ok := post.GetProp(model.PostPropsAttachments).([]*model.SlackAttachment) require.True(t, ok) diff --git a/server/channels/app/job.go b/server/channels/app/job.go index abbe2b38e6b..82816c14ef0 100644 --- a/server/channels/app/job.go +++ b/server/channels/app/job.go @@ -172,7 +172,7 @@ func (a *App) SessionHasPermissionToCreateJob(session model.Session, job *model. channelID := a.getChannelIDFromJobData(job.Data) if channelID != "" { // SECURE: Check specific channel permission - hasChannelPermission := a.HasPermissionToChannel(request.EmptyContext(a.Srv().Log()), session.UserId, channelID, model.PermissionManageChannelAccessRules) + hasChannelPermission, _ := a.HasPermissionToChannel(request.EmptyContext(a.Srv().Log()), session.UserId, channelID, model.PermissionManageChannelAccessRules) if hasChannelPermission { return true, model.PermissionManageChannelAccessRules } diff --git a/server/channels/app/notification.go b/server/channels/app/notification.go index ebd8de19721..f194b20ab6e 100644 --- a/server/channels/app/notification.go +++ b/server/channels/app/notification.go @@ -844,7 +844,7 @@ func (a *App) SendNotifications(rctx request.CTX, post *model.Post, team *model. a.sanitizeProfiles(userThread.Participants, false) userThread.Post.SanitizeProps() - sanitizedPost, err := a.SanitizePostMetadataForUser(rctx, userThread.Post, uid) + sanitizedPost, isMemberForPreview, err := a.SanitizePostMetadataForUser(rctx, userThread.Post, uid) if err != nil { a.CountNotificationReason(model.NotificationStatusError, model.NotificationTypeWebsocket, model.NotificationReasonParseError, model.NotificationNoPlatform) rctx.Logger().LogM(mlog.MlvlNotificationError, "Failed to sanitize metadata", @@ -868,6 +868,18 @@ func (a *App) SendNotifications(rctx request.CTX, post *model.Post, team *model. message.Add("previous_unread_mentions", previousUnreadMentions) message.Add("previous_unread_replies", previousUnreadReplies) + auditRec := a.MakeAuditRecord(rctx, model.AuditEventWebsocketPost, model.AuditStatusSuccess) + defer a.LogAuditRec(rctx, auditRec, nil) + model.AddEventParameterToAuditRec(auditRec, "post_id", userThread.Post.Id) + if !isMemberForPreview { + previewPost := userThread.Post.GetPreviewPost() + if previewPost != nil { + model.AddEventParameterToAuditRec(auditRec, "preview_post_id", previewPost.Post.Id) + } + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } + auditRec.Success() + a.Publish(message) } } @@ -1000,7 +1012,7 @@ func (a *App) RemoveNotifications(rctx request.CTX, post *model.Post, channel *m a.sanitizeProfiles(userThread.Participants, false) userThread.Post.SanitizeProps() - sanitizedPost, err1 := a.SanitizePostMetadataForUser(rctx, userThread.Post, userID) + sanitizedPost, isMemberForPreview, err1 := a.SanitizePostMetadataForUser(rctx, userThread.Post, userID) if err1 != nil { return err1 } @@ -1011,6 +1023,18 @@ func (a *App) RemoveNotifications(rctx request.CTX, post *model.Post, channel *m rctx.Logger().Warn("Failed to encode thread to JSON") } + auditRec := a.MakeAuditRecord(rctx, model.AuditEventWebsocketPost, model.AuditStatusSuccess) + defer a.LogAuditRec(rctx, auditRec, nil) + model.AddEventParameterToAuditRec(auditRec, "post_id", userThread.Post.Id) + if !isMemberForPreview { + previewPost := userThread.Post.GetPreviewPost() + if previewPost != nil { + model.AddEventParameterToAuditRec(auditRec, "preview_post_id", previewPost.Post.Id) + } + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } + auditRec.Success() + message := model.NewWebSocketEvent(model.WebsocketEventThreadUpdated, team.Id, "", userID, nil, "") message.Add("thread", string(payload)) message.Add("previous_unread_mentions", previousUnreadMentions) @@ -1443,7 +1467,7 @@ func getMentionsEnabledFields(post *model.Post) model.StringArray { // allowChannelMentions returns whether or not the channel mentions are allowed for the given post. func (a *App) allowChannelMentions(rctx request.CTX, post *model.Post, numProfiles int) bool { - if !a.HasPermissionToChannel(rctx, post.UserId, post.ChannelId, model.PermissionUseChannelMentions) { + if ok, _ := a.HasPermissionToChannel(rctx, post.UserId, post.ChannelId, model.PermissionUseChannelMentions); !ok { return false } @@ -1464,7 +1488,7 @@ func (a *App) allowGroupMentions(rctx request.CTX, post *model.Post) bool { return false } - if !a.HasPermissionToChannel(rctx, post.UserId, post.ChannelId, model.PermissionUseGroupMentions) { + if ok, _ := a.HasPermissionToChannel(rctx, post.UserId, post.ChannelId, model.PermissionUseGroupMentions); !ok { return false } diff --git a/server/channels/app/notification_test.go b/server/channels/app/notification_test.go index 2a89ef3e4c9..d8232fafe0c 100644 --- a/server/channels/app/notification_test.go +++ b/server/channels/app/notification_test.go @@ -44,7 +44,7 @@ func TestSendNotifications(t *testing.T) { _, appErr := th.App.AddUserToChannel(th.Context, th.BasicUser2, th.BasicChannel, false) require.Nil(t, appErr) - post1, createPostErr := th.App.CreatePostMissingChannel(th.Context, &model.Post{ + post1, _, createPostErr := th.App.CreatePostMissingChannel(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "@" + th.BasicUser2.Username, @@ -75,7 +75,7 @@ func TestSendNotifications(t *testing.T) { Message: fmt.Sprintf("hello @%s group", *group.Name), CreateAt: model.GetMillis() - 10000, } - groupMentionPost, createPostErr := th.App.CreatePost(th.Context, groupMentionPost, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + groupMentionPost, _, createPostErr := th.App.CreatePost(th.Context, groupMentionPost, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, createPostErr) mentions, err := th.App.SendNotifications(th.Context, groupMentionPost, th.BasicTeam, th.BasicChannel, th.BasicUser, nil, true) @@ -95,7 +95,7 @@ func TestSendNotifications(t *testing.T) { dm, appErr := th.App.GetOrCreateDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id) require.Nil(t, appErr) - post2, appErr := th.App.CreatePostMissingChannel(th.Context, &model.Post{ + post2, _, appErr := th.App.CreatePostMissingChannel(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: dm.Id, Message: "dm message", @@ -111,7 +111,7 @@ func TestSendNotifications(t *testing.T) { appErr = th.App.Srv().InvalidateAllCaches() require.Nil(t, appErr) - post3, appErr := th.App.CreatePostMissingChannel(th.Context, &model.Post{ + post3, _, appErr := th.App.CreatePostMissingChannel(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: dm.Id, Message: "dm message", @@ -136,7 +136,7 @@ func TestSendNotifications(t *testing.T) { } channel := th.CreateGroupChannel(t, users[0], users[1]) - post2, appErr := th.App.CreatePostMissingChannel(th.Context, &model.Post{ + post2, _, appErr := th.App.CreatePostMissingChannel(th.Context, &model.Post{ UserId: users[0].Id, ChannelId: channel.Id, Message: "gm message", @@ -152,7 +152,7 @@ func TestSendNotifications(t *testing.T) { appErr = th.App.Srv().InvalidateAllCaches() require.Nil(t, appErr) - post3, appErr := th.App.CreatePostMissingChannel(th.Context, &model.Post{ + post3, _, appErr := th.App.CreatePostMissingChannel(th.Context, &model.Post{ UserId: users[0].Id, ChannelId: channel.Id, Message: "gm message", @@ -178,7 +178,7 @@ func TestSendNotifications(t *testing.T) { Props: model.StringInterface{model.PostPropsFromWebhook: "true", model.PostPropsOverrideUsername: "a bot"}, } - rootPost, appErr := th.App.CreatePostMissingChannel(th.Context, rootPost, false, true) + rootPost, _, appErr := th.App.CreatePostMissingChannel(th.Context, rootPost, false, true) require.Nil(t, appErr) childPost := &model.Post{ @@ -187,7 +187,7 @@ func TestSendNotifications(t *testing.T) { RootId: rootPost.Id, Message: "a reply", } - childPost, appErr = th.App.CreatePostMissingChannel(th.Context, childPost, false, true) + childPost, _, appErr = th.App.CreatePostMissingChannel(th.Context, childPost, false, true) require.Nil(t, appErr) postList := model.PostList{ @@ -359,7 +359,7 @@ func TestSendNotifications_MentionsFollowers(t *testing.T) { } // Use CreatePost instead of SendNotifications here since we need that to set up some threads state - _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) + _, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) received1 := <-messages1 @@ -576,7 +576,7 @@ func TestSendNotificationsWithManyUsers(t *testing.T) { users = append(users, user) } - _, appErr1 := th.App.CreatePostMissingChannel(th.Context, &model.Post{ + _, _, appErr1 := th.App.CreatePostMissingChannel(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "@channel", @@ -596,7 +596,7 @@ func TestSendNotificationsWithManyUsers(t *testing.T) { } }) - _, appErr1 = th.App.CreatePostMissingChannel(th.Context, &model.Post{ + _, _, appErr1 = th.App.CreatePostMissingChannel(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "@channel", @@ -2843,7 +2843,7 @@ func TestReplyPostNotificationsWithCRT(t *testing.T) { Message: "root post by user1", UserId: u1.Id, } - rpost, appErr := th.App.CreatePost(th.Context, rootPost, c1, model.CreatePostFlags{SetOnline: true}) + rpost, _, appErr := th.App.CreatePost(th.Context, rootPost, c1, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) replyPost1 := &model.Post{ @@ -2852,7 +2852,7 @@ func TestReplyPostNotificationsWithCRT(t *testing.T) { UserId: u2.Id, RootId: rpost.Id, } - _, appErr = th.App.CreatePost(th.Context, replyPost1, c1, model.CreatePostFlags{SetOnline: true}) + _, _, appErr = th.App.CreatePost(th.Context, replyPost1, c1, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) replyPost2 := &model.Post{ @@ -2861,7 +2861,7 @@ func TestReplyPostNotificationsWithCRT(t *testing.T) { UserId: u1.Id, RootId: rpost.Id, } - _, appErr = th.App.CreatePost(th.Context, replyPost2, c1, model.CreatePostFlags{SetOnline: true}) + _, _, appErr = th.App.CreatePost(th.Context, replyPost2, c1, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) threadMembership, appErr := th.App.GetThreadMembershipForUser(u2.Id, rpost.Id) @@ -2892,7 +2892,7 @@ func TestReplyPostNotificationsWithCRT(t *testing.T) { Props: model.StringInterface{model.PostPropsFromWebhook: "true", model.PostPropsOverrideUsername: "a bot"}, } - rootPost, appErr := th.App.CreatePostMissingChannel(th.Context, rootPost, false, true) + rootPost, _, appErr := th.App.CreatePostMissingChannel(th.Context, rootPost, false, true) require.Nil(t, appErr) childPost := &model.Post{ @@ -2901,7 +2901,7 @@ func TestReplyPostNotificationsWithCRT(t *testing.T) { RootId: rootPost.Id, Message: "a reply", } - childPost, appErr = th.App.CreatePostMissingChannel(th.Context, childPost, false, true) + childPost, _, appErr = th.App.CreatePostMissingChannel(th.Context, childPost, false, true) require.Nil(t, appErr) postList := model.PostList{ @@ -2936,7 +2936,7 @@ func TestReplyPostNotificationsWithCRT(t *testing.T) { Message: "root post by user1", UserId: u1.Id, } - rpost, appErr := th.App.CreatePost(th.Context, rootPost, c1, model.CreatePostFlags{SetOnline: true}) + rpost, _, appErr := th.App.CreatePost(th.Context, rootPost, c1, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) // Remove user1 from the channel @@ -2949,7 +2949,7 @@ func TestReplyPostNotificationsWithCRT(t *testing.T) { UserId: u2.Id, RootId: rpost.Id, } - _, appErr = th.App.CreatePost(th.Context, replyPost, c1, model.CreatePostFlags{SetOnline: true}) + _, _, appErr = th.App.CreatePost(th.Context, replyPost, c1, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) // Ensure user1 is not auto-following the thread @@ -2981,7 +2981,7 @@ func TestChannelAutoFollowThreads(t *testing.T) { Message: "root post by user3", UserId: u3.Id, } - rpost, appErr := th.App.CreatePost(th.Context, rootPost, c1, model.CreatePostFlags{SetOnline: true}) + rpost, _, appErr := th.App.CreatePost(th.Context, rootPost, c1, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) replyPost1 := &model.Post{ @@ -2990,7 +2990,7 @@ func TestChannelAutoFollowThreads(t *testing.T) { UserId: u1.Id, RootId: rpost.Id, } - _, appErr = th.App.CreatePost(th.Context, replyPost1, c1, model.CreatePostFlags{SetOnline: true}) + _, _, appErr = th.App.CreatePost(th.Context, replyPost1, c1, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) // user-2 starts auto-following thread @@ -3012,7 +3012,7 @@ func TestChannelAutoFollowThreads(t *testing.T) { UserId: u1.Id, RootId: rpost.Id, } - _, appErr = th.App.CreatePost(th.Context, replyPost2, c1, model.CreatePostFlags{SetOnline: true}) + _, _, appErr = th.App.CreatePost(th.Context, replyPost2, c1, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) // Do NOT start auto-following thread, once "un-followed" @@ -3043,7 +3043,7 @@ func TestRemoveNotifications(t *testing.T) { Message: "root post by user1", UserId: u1.Id, } - rootPost, appErr := th.App.CreatePost(th.Context, rootPost, c1, model.CreatePostFlags{SetOnline: true}) + rootPost, _, appErr := th.App.CreatePost(th.Context, rootPost, c1, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) replyPost1 := &model.Post{ @@ -3052,7 +3052,7 @@ func TestRemoveNotifications(t *testing.T) { UserId: u2.Id, RootId: rootPost.Id, } - _, appErr = th.App.CreatePost(th.Context, replyPost1, c1, model.CreatePostFlags{SetOnline: true}) + _, _, appErr = th.App.CreatePost(th.Context, replyPost1, c1, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) replyPost2 := &model.Post{ @@ -3061,7 +3061,7 @@ func TestRemoveNotifications(t *testing.T) { UserId: u1.Id, RootId: rootPost.Id, } - replyPost2, appErr = th.App.CreatePost(th.Context, replyPost2, c1, model.CreatePostFlags{SetOnline: true}) + replyPost2, _, appErr = th.App.CreatePost(th.Context, replyPost2, c1, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) _, appErr = th.App.DeletePost(th.Context, replyPost2.Id, u1.Id) @@ -3100,7 +3100,7 @@ func TestRemoveNotifications(t *testing.T) { Message: "root post by user1", UserId: u1.Id, } - rootPost, appErr = th.App.CreatePost(th.Context, rootPost, c1, model.CreatePostFlags{SetOnline: true}) + rootPost, _, appErr = th.App.CreatePost(th.Context, rootPost, c1, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) replyPost1 := &model.Post{ @@ -3109,7 +3109,7 @@ func TestRemoveNotifications(t *testing.T) { UserId: u2.Id, RootId: rootPost.Id, } - _, appErr = th.App.CreatePost(th.Context, replyPost1, c1, model.CreatePostFlags{SetOnline: true}) + _, _, appErr = th.App.CreatePost(th.Context, replyPost1, c1, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) replyPost2 := &model.Post{ @@ -3118,7 +3118,7 @@ func TestRemoveNotifications(t *testing.T) { UserId: u1.Id, RootId: rootPost.Id, } - replyPost2, appErr = th.App.CreatePost(th.Context, replyPost2, c1, model.CreatePostFlags{SetOnline: true}) + replyPost2, _, appErr = th.App.CreatePost(th.Context, replyPost2, c1, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) _, appErr = th.App.DeletePost(th.Context, replyPost2.Id, u1.Id) diff --git a/server/channels/app/notify_admin.go b/server/channels/app/notify_admin.go index 016214b778a..bf33b1936f3 100644 --- a/server/channels/app/notify_admin.go +++ b/server/channels/app/notify_admin.go @@ -164,7 +164,7 @@ func (a *App) upgradePlanAdminNotifyPost(rctx request.CTX, workspaceName string, props["trial"] = trial post.SetProps(props) - _, appErr = a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}) + _, _, appErr = a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}) if appErr != nil { rctx.Logger().Warn("Error creating post", mlog.Err(appErr)) diff --git a/server/channels/app/permissions_migrations_test.go b/server/channels/app/permissions_migrations_test.go index 7e382d14ec1..6e4987c7e8e 100644 --- a/server/channels/app/permissions_migrations_test.go +++ b/server/channels/app/permissions_migrations_test.go @@ -4,7 +4,6 @@ package app import ( - "sort" "testing" "github.com/stretchr/testify/assert" @@ -200,8 +199,7 @@ func TestApplyPermissionsMap(t *testing.T) { for _, tc := range tt { t.Run(tc.Name, func(t *testing.T) { result := applyPermissionsMap(&model.Role{Name: "system_admin"}, tc.RoleMap, tc.TranslationMap) - sort.Strings(result) - assert.Equal(t, tc.ExpectedResult, result) + assert.ElementsMatch(t, tc.ExpectedResult, result) }) } } @@ -272,8 +270,7 @@ func TestApplyPermissionsMapToSchemeRole(t *testing.T) { for _, tc := range tt { t.Run(tc.Name, func(t *testing.T) { result := applyPermissionsMap(&model.Role{Name: schemeRoleName, DisplayName: sqlstore.SchemeRoleDisplayNameTeamAdmin}, tc.RoleMap, tc.TranslationMap) - sort.Strings(result) - assert.Equal(t, tc.ExpectedResult, result) + assert.ElementsMatch(t, tc.ExpectedResult, result) }) } } diff --git a/server/channels/app/platform/helper_test.go b/server/channels/app/platform/helper_test.go index 47691ee3382..19c4c2dbf62 100644 --- a/server/channels/app/platform/helper_test.go +++ b/server/channels/app/platform/helper_test.go @@ -56,14 +56,24 @@ func (ms *mockSuite) UserCanSeeOtherUser(rctx request.CTX, userID string, otherU return true, nil } -func (ms *mockSuite) HasPermissionToReadChannel(rctx request.CTX, userID string, channel *model.Channel) bool { - return true +func (ms *mockSuite) HasPermissionToReadChannel(rctx request.CTX, userID string, channel *model.Channel) (bool, bool) { + return true, true } func (ms *mockSuite) MFARequired(rctx request.CTX) *model.AppError { return nil } +func (ms *mockSuite) MakeAuditRecord(rctx request.CTX, event string, initialStatus string) *model.AuditRecord { + return &model.AuditRecord{ + Status: initialStatus, + EventName: event, + } +} + +func (ms *mockSuite) LogAuditRec(rctx request.CTX, auditRec *model.AuditRecord, err error) { +} + func setupDBStore(tb testing.TB) (store.Store, *model.SqlSettings) { var dbStore store.Store var dbSettings *model.SqlSettings diff --git a/server/channels/app/platform/memory_darwin.go b/server/channels/app/platform/memory_darwin.go new file mode 100644 index 00000000000..1d1a2bb9d18 --- /dev/null +++ b/server/channels/app/platform/memory_darwin.go @@ -0,0 +1,19 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//go:build darwin + +package platform + +import ( + "golang.org/x/sys/unix" +) + +// getTotalMemory returns the total system memory in bytes. +func getTotalMemory() (uint64, error) { + mem, err := unix.SysctlUint64("hw.memsize") + if err != nil { + return 0, err + } + return mem, nil +} diff --git a/server/channels/app/platform/memory_linux.go b/server/channels/app/platform/memory_linux.go new file mode 100644 index 00000000000..2ca61a1deb7 --- /dev/null +++ b/server/channels/app/platform/memory_linux.go @@ -0,0 +1,21 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//go:build linux + +package platform + +import ( + "syscall" +) + +// getTotalMemory returns the total system memory in bytes. +func getTotalMemory() (uint64, error) { + var info syscall.Sysinfo_t + err := syscall.Sysinfo(&info) + if err != nil { + return 0, err + } + // Sysinfo returns memory in units of info.Unit bytes + return info.Totalram * uint64(info.Unit), nil +} diff --git a/server/channels/app/platform/memory_other.go b/server/channels/app/platform/memory_other.go new file mode 100644 index 00000000000..e52541b6347 --- /dev/null +++ b/server/channels/app/platform/memory_other.go @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +//go:build !linux && !darwin + +package platform + +import "errors" + +// ErrMemoryUnsupportedPlatform is returned when memory detection is not supported on the current platform. +var ErrMemoryUnsupportedPlatform = errors.New("total memory detection not supported on this platform") + +// getTotalMemory returns the total system memory in bytes. +// On unsupported platforms, this returns an error. +func getTotalMemory() (uint64, error) { + return 0, ErrMemoryUnsupportedPlatform +} diff --git a/server/channels/app/platform/mocks/SuiteIFace.go b/server/channels/app/platform/mocks/SuiteIFace.go index 7161bef253c..52198075aa1 100644 --- a/server/channels/app/platform/mocks/SuiteIFace.go +++ b/server/channels/app/platform/mocks/SuiteIFace.go @@ -49,7 +49,7 @@ func (_m *SuiteIFace) GetSession(token string) (*model.Session, *model.AppError) } // HasPermissionToReadChannel provides a mock function with given fields: rctx, userID, channel -func (_m *SuiteIFace) HasPermissionToReadChannel(rctx request.CTX, userID string, channel *model.Channel) bool { +func (_m *SuiteIFace) HasPermissionToReadChannel(rctx request.CTX, userID string, channel *model.Channel) (bool, bool) { ret := _m.Called(rctx, userID, channel) if len(ret) == 0 { @@ -57,13 +57,28 @@ func (_m *SuiteIFace) HasPermissionToReadChannel(rctx request.CTX, userID string } var r0 bool + var r1 bool + if rf, ok := ret.Get(0).(func(request.CTX, string, *model.Channel) (bool, bool)); ok { + return rf(rctx, userID, channel) + } if rf, ok := ret.Get(0).(func(request.CTX, string, *model.Channel) bool); ok { r0 = rf(rctx, userID, channel) } else { r0 = ret.Get(0).(bool) } - return r0 + if rf, ok := ret.Get(1).(func(request.CTX, string, *model.Channel) bool); ok { + r1 = rf(rctx, userID, channel) + } else { + r1 = ret.Get(1).(bool) + } + + return r0, r1 +} + +// LogAuditRec provides a mock function with given fields: rctx, auditRec, err +func (_m *SuiteIFace) LogAuditRec(rctx request.CTX, auditRec *model.AuditRecord, err error) { + _m.Called(rctx, auditRec, err) } // MFARequired provides a mock function with given fields: rctx @@ -86,6 +101,26 @@ func (_m *SuiteIFace) MFARequired(rctx request.CTX) *model.AppError { return r0 } +// MakeAuditRecord provides a mock function with given fields: rctx, event, initialStatus +func (_m *SuiteIFace) MakeAuditRecord(rctx request.CTX, event string, initialStatus string) *model.AuditRecord { + ret := _m.Called(rctx, event, initialStatus) + + if len(ret) == 0 { + panic("no return value specified for MakeAuditRecord") + } + + var r0 *model.AuditRecord + if rf, ok := ret.Get(0).(func(request.CTX, string, string) *model.AuditRecord); ok { + r0 = rf(rctx, event, initialStatus) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AuditRecord) + } + } + + return r0 +} + // RolesGrantPermission provides a mock function with given fields: roleNames, permissionId func (_m *SuiteIFace) RolesGrantPermission(roleNames []string, permissionId string) bool { ret := _m.Called(roleNames, permissionId) diff --git a/server/channels/app/platform/service.go b/server/channels/app/platform/service.go index e2750b0bb4e..c597e0d5c2e 100644 --- a/server/channels/app/platform/service.go +++ b/server/channels/app/platform/service.go @@ -635,7 +635,7 @@ func (ps *PlatformService) LdapDiagnostic() einterfaces.LdapDiagnosticInterface return ps.ldapDiagnostic } -// DatabaseTypeAndSchemaVersion returns the Database type (postgres or mysql) and current version of the schema +// DatabaseTypeAndSchemaVersion returns the database type and current version of the schema func (ps *PlatformService) DatabaseTypeAndSchemaVersion() (string, string, error) { schemaVersion, err := ps.Store.GetDBSchemaVersion() if err != nil { diff --git a/server/channels/app/platform/service_test.go b/server/channels/app/platform/service_test.go index 3198a676a1e..1d940256c60 100644 --- a/server/channels/app/platform/service_test.go +++ b/server/channels/app/platform/service_test.go @@ -242,13 +242,9 @@ func TestDatabaseTypeAndMattermostVersion(t *testing.T) { databaseType, schemaVersion, err := th.Service.DatabaseTypeAndSchemaVersion() require.NoError(t, err) - if *th.Service.Config().SqlSettings.DriverName == model.DatabaseDriverPostgres { - assert.Equal(t, "postgres", databaseType) - } else { - assert.Equal(t, "mysql", databaseType) - } + assert.Equal(t, "postgres", databaseType) - // It's hard to check wheather the schema version is correct or not. + // It's hard to check whether the schema version is correct or not. // So, we just check if it's greater than 1. assert.GreaterOrEqual(t, schemaVersion, strconv.Itoa(1)) } diff --git a/server/channels/app/platform/support_packet.go b/server/channels/app/platform/support_packet.go index ed4832ddc95..b222cd413eb 100644 --- a/server/channels/app/platform/support_packet.go +++ b/server/channels/app/platform/support_packet.go @@ -95,6 +95,14 @@ func (ps *PlatformService) getSupportPacketDiagnostics(rctx request.CTX) (*model /* Server */ d.Server.OS = runtime.GOOS d.Server.Architecture = runtime.GOARCH + // Note: These values represent the host machine's resources, not any + // container limits (e.g., Docker or Kubernetes) that may be in effect. + d.Server.CPUCores = runtime.NumCPU() + totalMemoryBytes, err := getTotalMemory() + if err != nil { + rErr = multierror.Append(rErr, errors.Wrap(err, "error while getting total memory")) + } + d.Server.TotalMemoryMB = totalMemoryBytes / 1024 / 1024 d.Server.Hostname, err = os.Hostname() if err != nil { rErr = multierror.Append(errors.Wrap(err, "error while getting hostname")) diff --git a/server/channels/app/platform/support_packet_test.go b/server/channels/app/platform/support_packet_test.go index 7aa15dcb07d..4dfbb21e89a 100644 --- a/server/channels/app/platform/support_packet_test.go +++ b/server/channels/app/platform/support_packet_test.go @@ -204,6 +204,8 @@ func TestGetSupportPacketDiagnostics(t *testing.T) { assert.Equal(t, model.CurrentVersion, d.Server.Version) // BuildHash is not present in tests assert.Equal(t, "docker", d.Server.InstallationType) + assert.Positive(t, d.Server.CPUCores) + assert.Positive(t, d.Server.TotalMemoryMB) /* Config */ assert.Equal(t, "memory://", d.Config.Source) diff --git a/server/channels/app/platform/web_hub.go b/server/channels/app/platform/web_hub.go index 18fb4da9a5e..502903d265a 100644 --- a/server/channels/app/platform/web_hub.go +++ b/server/channels/app/platform/web_hub.go @@ -28,9 +28,11 @@ const ( type SuiteIFace interface { GetSession(token string) (*model.Session, *model.AppError) RolesGrantPermission(roleNames []string, permissionId string) bool - HasPermissionToReadChannel(rctx request.CTX, userID string, channel *model.Channel) bool + HasPermissionToReadChannel(rctx request.CTX, userID string, channel *model.Channel) (bool, bool) UserCanSeeOtherUser(rctx request.CTX, userID string, otherUserId string) (bool, *model.AppError) MFARequired(rctx request.CTX) *model.AppError + MakeAuditRecord(rctx request.CTX, event string, initialStatus string) *model.AuditRecord + LogAuditRec(rctx request.CTX, auditRec *model.AuditRecord, err error) } type webConnActivityMessage struct { diff --git a/server/channels/app/plugin_api.go b/server/channels/app/plugin_api.go index 61d169e0fc7..fbaa15c0041 100644 --- a/server/channels/app/plugin_api.go +++ b/server/channels/app/plugin_api.go @@ -591,7 +591,7 @@ func (api *PluginAPI) SearchPostsInTeamForUser(teamID string, userID string, sea includeDeletedChannels = *searchParams.IncludeDeletedChannels } - results, appErr := api.app.SearchPostsForUser(api.ctx, terms, userID, teamID, isOrSearch, includeDeletedChannels, timeZoneOffset, page, perPage) + results, _, appErr := api.app.SearchPostsForUser(api.ctx, terms, userID, teamID, isOrSearch, includeDeletedChannels, timeZoneOffset, page, perPage) if results != nil { results = results.ForPlugin() } @@ -773,7 +773,7 @@ func (api *PluginAPI) DeleteGroupSyncable(groupID string, syncableID string, syn func (api *PluginAPI) CreatePost(post *model.Post) (*model.Post, *model.AppError) { post.AddProp(model.PostPropsFromPlugin, "true") - post, appErr := api.app.CreatePostMissingChannel(api.ctx, post, true, true) + post, _, appErr := api.app.CreatePostMissingChannel(api.ctx, post, true, true) if post != nil { post = post.ForPlugin() } @@ -793,11 +793,13 @@ func (api *PluginAPI) GetReactions(postID string) ([]*model.Reaction, *model.App } func (api *PluginAPI) SendEphemeralPost(userID string, post *model.Post) *model.Post { - return api.app.SendEphemeralPost(api.ctx, userID, post).ForPlugin() + newPost, _ := api.app.SendEphemeralPost(api.ctx, userID, post) + return newPost.ForPlugin() } func (api *PluginAPI) UpdateEphemeralPost(userID string, post *model.Post) *model.Post { - return api.app.UpdateEphemeralPost(api.ctx, userID, post).ForPlugin() + newPost, _ := api.app.UpdateEphemeralPost(api.ctx, userID, post) + return newPost.ForPlugin() } func (api *PluginAPI) DeleteEphemeralPost(userID, postID string) { @@ -858,7 +860,7 @@ func (api *PluginAPI) GetPostsForChannel(channelID string, page, perPage int) (* } func (api *PluginAPI) UpdatePost(post *model.Post) (*model.Post, *model.AppError) { - post, appErr := api.app.UpdatePost(api.ctx, post, &model.UpdatePostOptions{SafeUpdate: false}) + post, _, appErr := api.app.UpdatePost(api.ctx, post, &model.UpdatePostOptions{SafeUpdate: false}) if post != nil { post = post.ForPlugin() } @@ -1104,7 +1106,8 @@ func (api *PluginAPI) HasPermissionToTeam(userID, teamID string, permission *mod } func (api *PluginAPI) HasPermissionToChannel(userID, channelID string, permission *model.Permission) bool { - return api.app.HasPermissionToChannel(api.ctx, userID, channelID, permission) + ok, _ := api.app.HasPermissionToChannel(api.ctx, userID, channelID, permission) + return ok } func (api *PluginAPI) RolesGrantPermission(roleNames []string, permissionId string) bool { diff --git a/server/channels/app/plugin_hooks_test.go b/server/channels/app/plugin_hooks_test.go index f8ee129b59b..6c6fca87adf 100644 --- a/server/channels/app/plugin_hooks_test.go +++ b/server/channels/app/plugin_hooks_test.go @@ -111,7 +111,7 @@ func TestHookMessageWillBePosted(t *testing.T) { Message: "message_", CreateAt: model.GetMillis() - 10000, } - _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + _, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) if assert.NotNil(t, err) { assert.Equal(t, "Post rejected by plugin. rejected", err.Message) } @@ -152,7 +152,7 @@ func TestHookMessageWillBePosted(t *testing.T) { Message: "message_", CreateAt: model.GetMillis() - 10000, } - _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + _, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) if assert.NotNil(t, err) { assert.Equal(t, "Post rejected by plugin. rejected", err.Message) } @@ -192,7 +192,7 @@ func TestHookMessageWillBePosted(t *testing.T) { Message: "message", CreateAt: model.GetMillis() - 10000, } - post, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + post, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) assert.Equal(t, "message", post.Message) @@ -236,7 +236,7 @@ func TestHookMessageWillBePosted(t *testing.T) { Message: "message", CreateAt: model.GetMillis() - 10000, } - post, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + post, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) assert.Equal(t, "message_fromplugin", post.Message) @@ -302,7 +302,7 @@ func TestHookMessageWillBePosted(t *testing.T) { Message: "message", CreateAt: model.GetMillis() - 10000, } - post, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + post, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) assert.Equal(t, "prefix_message_suffix", post.Message) }) @@ -347,7 +347,7 @@ func TestHookMessageHasBeenPosted(t *testing.T) { Message: "message", CreateAt: model.GetMillis() - 10000, } - _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + _, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) } @@ -387,11 +387,11 @@ func TestHookMessageWillBeUpdated(t *testing.T) { Message: "message_", CreateAt: model.GetMillis() - 10000, } - post, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + post, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) assert.Equal(t, "message_", post.Message) post.Message = post.Message + "edited_" - post, err = th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true}) + post, _, err = th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true}) require.Nil(t, err) assert.Equal(t, "message_edited_fromplugin", post.Message) } @@ -436,11 +436,11 @@ func TestHookMessageHasBeenUpdated(t *testing.T) { Message: "message_", CreateAt: model.GetMillis() - 10000, } - post, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + post, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) assert.Equal(t, "message_", post.Message) post.Message = post.Message + "edited" - _, err = th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true}) + _, _, err = th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true}) require.Nil(t, err) } @@ -483,7 +483,7 @@ func TestHookMessageHasBeenDeleted(t *testing.T) { Message: "message", CreateAt: model.GetMillis() - 10000, } - _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + _, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) _, err = th.App.DeletePost(th.Context, post.Id, th.BasicUser.Id) require.Nil(t, err) @@ -1062,7 +1062,7 @@ func TestHookContext(t *testing.T) { Message: "not this", CreateAt: model.GetMillis() - 10000, } - _, err := th.App.CreatePost(ctx, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + _, _, err := th.App.CreatePost(ctx, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) } @@ -1766,7 +1766,7 @@ func TestHookMessagesWillBeConsumed(t *testing.T) { Message: "message", CreateAt: model.GetMillis() - 10000, } - _, err := th.App.CreatePost(th.Context, newPost, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + _, _, err := th.App.CreatePost(th.Context, newPost, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) post, err := th.App.GetSinglePost(th.Context, newPost.Id, true) @@ -1789,7 +1789,7 @@ func TestHookMessagesWillBeConsumed(t *testing.T) { Message: "message", CreateAt: model.GetMillis() - 10000, } - _, err := th.App.CreatePost(th.Context, newPost, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + _, _, err := th.App.CreatePost(th.Context, newPost, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) post, err := th.App.GetSinglePost(th.Context, newPost.Id, true) diff --git a/server/channels/app/plugin_install_test.go b/server/channels/app/plugin_install_test.go index 4eb2d56bd51..0bf1f5a64cf 100644 --- a/server/channels/app/plugin_install_test.go +++ b/server/channels/app/plugin_install_test.go @@ -11,7 +11,6 @@ import ( "io" "os" "path/filepath" - "sort" "testing" "github.com/stretchr/testify/assert" @@ -61,12 +60,6 @@ func makeInMemoryGzipTarFile(t *testing.T, files []testFile) *bytes.Reader { return bytes.NewReader(buf.Bytes()) } -type byBundleInfoID []*model.BundleInfo - -func (b byBundleInfoID) Len() int { return len(b) } -func (b byBundleInfoID) Swap(i, j int) { b[i], b[j] = b[j], b[i] } -func (b byBundleInfoID) Less(i, j int) bool { return b[i].Manifest.Id < b[j].Manifest.Id } - func TestInstallPluginLocally(t *testing.T) { mainHelper.Parallel(t) t.Run("invalid tar", func(t *testing.T) { @@ -142,14 +135,12 @@ func TestInstallPluginLocally(t *testing.T) { bundleInfos, err := pluginsEnvironment.Available() require.NoError(t, err) - sort.Sort(byBundleInfoID(bundleInfos)) - actualManifests := make([]*model.Manifest, 0, len(bundleInfos)) for _, bundleInfo := range bundleInfos { actualManifests = append(actualManifests, bundleInfo.Manifest) } - require.Equal(t, manifests, actualManifests) + require.ElementsMatch(t, manifests, actualManifests) } t.Run("no plugins already installed", func(t *testing.T) { diff --git a/server/channels/app/plugin_test.go b/server/channels/app/plugin_test.go index 49c8f95a1a6..d1e1913b56d 100644 --- a/server/channels/app/plugin_test.go +++ b/server/channels/app/plugin_test.go @@ -639,7 +639,7 @@ func TestPluginPanicLogs(t *testing.T) { Message: "message_", CreateAt: model.GetMillis() - 10000, } - _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + _, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) assert.Nil(t, err) th.TestLogger.Flush() diff --git a/server/channels/app/post.go b/server/channels/app/post.go index 84430b93c78..98f0c6d37be 100644 --- a/server/channels/app/post.go +++ b/server/channels/app/post.go @@ -15,6 +15,9 @@ import ( "sync" "time" + "maps" + "slices" + agentclient "github.com/mattermost/mattermost-plugin-ai/public/bridgeclient" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" @@ -35,41 +38,41 @@ const ( var atMentionPattern = regexp.MustCompile(`\B@`) -func (a *App) CreatePostAsUser(rctx request.CTX, post *model.Post, currentSessionId string, setOnline bool) (*model.Post, *model.AppError) { +func (a *App) CreatePostAsUser(rctx request.CTX, post *model.Post, currentSessionId string, setOnline bool) (*model.Post, bool, *model.AppError) { // Check that channel has not been deleted channel, errCh := a.Srv().Store().Channel().Get(post.ChannelId, true) if errCh != nil { err := model.NewAppError("CreatePostAsUser", "api.context.invalid_param.app_error", map[string]any{"Name": "post.channel_id"}, "", http.StatusBadRequest).Wrap(errCh) - return nil, err + return nil, false, err } if strings.HasPrefix(post.Type, model.PostSystemMessagePrefix) { err := model.NewAppError("CreatePostAsUser", "api.context.invalid_param.app_error", map[string]any{"Name": "post.type"}, "", http.StatusBadRequest) - return nil, err + return nil, false, err } if channel.DeleteAt != 0 { err := model.NewAppError("createPost", "api.post.create_post.can_not_post_to_deleted.error", nil, "", http.StatusBadRequest) - return nil, err + return nil, false, err } restrictDM, err := a.CheckIfChannelIsRestrictedDM(rctx, channel) if err != nil { - return nil, err + return nil, false, err } if restrictDM { - return nil, model.NewAppError("createPost", "api.post.create_post.can_not_post_in_restricted_dm.error", nil, "", http.StatusBadRequest) + return nil, false, model.NewAppError("createPost", "api.post.create_post.can_not_post_in_restricted_dm.error", nil, "", http.StatusBadRequest) } - rp, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{TriggerWebhooks: true, SetOnline: setOnline}) + rp, isMemberForPreviews, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{TriggerWebhooks: true, SetOnline: setOnline}) if err != nil { if err.Id == "api.post.create_post.root_id.app_error" || err.Id == "api.post.create_post.channel_root_id.app_error" { err.StatusCode = http.StatusBadRequest } - return nil, err + return nil, false, err } // Update the Channel LastViewAt only if: @@ -91,19 +94,19 @@ func (a *App) CreatePostAsUser(rctx request.CTX, post *model.Post, currentSessio } } - return rp, nil + return rp, isMemberForPreviews, nil } -func (a *App) CreatePostMissingChannel(rctx request.CTX, post *model.Post, triggerWebhooks bool, setOnline bool) (*model.Post, *model.AppError) { +func (a *App) CreatePostMissingChannel(rctx request.CTX, post *model.Post, triggerWebhooks bool, setOnline bool) (*model.Post, bool, *model.AppError) { channel, err := a.Srv().Store().Channel().Get(post.ChannelId, true) if err != nil { errCtx := map[string]any{"channel_id": post.ChannelId} var nfErr *store.ErrNotFound switch { case errors.As(err, &nfErr): - return nil, model.NewAppError("CreatePostMissingChannel", "app.channel.get.existing.app_error", errCtx, "", http.StatusNotFound).Wrap(err) + return nil, false, model.NewAppError("CreatePostMissingChannel", "app.channel.get.existing.app_error", errCtx, "", http.StatusNotFound).Wrap(err) default: - return nil, model.NewAppError("CreatePostMissingChannel", "app.channel.get.find.app_error", errCtx, "", http.StatusInternalServerError).Wrap(err) + return nil, false, model.NewAppError("CreatePostMissingChannel", "app.channel.get.find.app_error", errCtx, "", http.StatusInternalServerError).Wrap(err) } } @@ -144,7 +147,7 @@ func (a *App) deduplicateCreatePost(rctx request.CTX, post *model.Post) (foundPo // If the other thread finished creating the post, return the created post back to the // client, making the API call feel idempotent. - actualPost, err := a.GetPostIfAuthorized(rctx, postID, rctx.Session(), false) + actualPost, err, _ := a.GetPostIfAuthorized(rctx, postID, rctx.Session(), false) if err != nil && err.StatusCode == http.StatusForbidden { rctx.Logger().Warn("Ignoring pending_post_id for which the user is unauthorized", mlog.String("pending_post_id", post.PendingPostId), mlog.String("post_id", postID), mlog.Err(err)) return nil, nil @@ -157,17 +160,25 @@ func (a *App) deduplicateCreatePost(rctx request.CTX, post *model.Post) (foundPo return actualPost, nil } -func (a *App) CreatePost(rctx request.CTX, post *model.Post, channel *model.Channel, flags model.CreatePostFlags) (savedPost *model.Post, err *model.AppError) { +func (a *App) CreatePost(rctx request.CTX, post *model.Post, channel *model.Channel, flags model.CreatePostFlags) (savedPost *model.Post, isMemberForPreviews bool, err *model.AppError) { if !a.Config().FeatureFlags.EnableSharedChannelsDMs && channel.IsShared() && (channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup) { - return nil, model.NewAppError("CreatePost", "app.post.create_post.shared_dm_or_gm.app_error", nil, "", http.StatusBadRequest) + return nil, false, model.NewAppError("CreatePost", "app.post.create_post.shared_dm_or_gm.app_error", nil, "", http.StatusBadRequest) } foundPost, err := a.deduplicateCreatePost(rctx, post) if err != nil { - return nil, err + return nil, false, err } if foundPost != nil { - return foundPost, nil + isMemberForPreviews = true + if previewPost := foundPost.GetPreviewPost(); previewPost != nil { + var member *model.ChannelMember + member, err = a.GetChannelMember(rctx, previewPost.Post.ChannelId, rctx.Session().UserId) + if err != nil || member == nil { + isMemberForPreviews = false + } + } + return foundPost, isMemberForPreviews, nil } // If we get this far, we've recorded the client-provided pending post id to the cache. @@ -200,7 +211,7 @@ func (a *App) CreatePost(rctx request.CTX, post *model.Post, channel *model.Chan return nil }) if err != nil { - return nil, model.NewAppError("CreatePost", "api.post.post_priority.persistent_notification_validation_error.request_error", nil, "", http.StatusInternalServerError).Wrap(err) + return nil, false, model.NewAppError("CreatePost", "api.post.post_priority.persistent_notification_validation_error.request_error", nil, "", http.StatusInternalServerError).Wrap(err) } } @@ -221,9 +232,9 @@ func (a *App) CreatePost(rctx request.CTX, post *model.Post, channel *model.Chan var nfErr *store.ErrNotFound switch { case errors.As(nErr, &nfErr): - return nil, model.NewAppError("CreatePost", MissingAccountError, nil, "", http.StatusNotFound).Wrap(nErr) + return nil, false, model.NewAppError("CreatePost", MissingAccountError, nil, "", http.StatusNotFound).Wrap(nErr) default: - return nil, model.NewAppError("CreatePost", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + return nil, false, model.NewAppError("CreatePost", "app.user.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } } @@ -240,16 +251,18 @@ func (a *App) CreatePost(rctx request.CTX, post *model.Post, channel *model.Chan } var ephemeralPost *model.Post - if post.Type == "" && !a.HasPermissionToChannel(rctx, user.Id, channel.Id, model.PermissionUseChannelMentions) { - mention := post.DisableMentionHighlights() - if mention != "" { - T := i18n.GetUserTranslations(user.Locale) - ephemeralPost = &model.Post{ - UserId: user.Id, - RootId: post.RootId, - ChannelId: channel.Id, - Message: T("model.post.channel_notifications_disabled_in_channel.message", model.StringInterface{"ChannelName": channel.Name, "Mention": mention}), - Props: model.StringInterface{model.PostPropsMentionHighlightDisabled: true}, + if post.Type == "" { + if hasPermission, _ := a.HasPermissionToChannel(rctx, user.Id, channel.Id, model.PermissionUseChannelMentions); !hasPermission { + mention := post.DisableMentionHighlights() + if mention != "" { + T := i18n.GetUserTranslations(user.Locale) + ephemeralPost = &model.Post{ + UserId: user.Id, + RootId: post.RootId, + ChannelId: channel.Id, + Message: T("model.post.channel_notifications_disabled_in_channel.message", model.StringInterface{"ChannelName": channel.Name, "Mention": mention}), + Props: model.StringInterface{model.PostPropsMentionHighlightDisabled: true}, + } } } } @@ -259,27 +272,27 @@ func (a *App) CreatePost(rctx request.CTX, post *model.Post, channel *model.Chan if pchan != nil { result := <-pchan if result.NErr != nil { - return nil, model.NewAppError("createPost", "api.post.create_post.root_id.app_error", nil, "", http.StatusBadRequest).Wrap(result.NErr) + return nil, false, model.NewAppError("createPost", "api.post.create_post.root_id.app_error", nil, "", http.StatusBadRequest).Wrap(result.NErr) } parentPostList = result.Data if len(parentPostList.Posts) == 0 || !parentPostList.IsChannelId(post.ChannelId) { - return nil, model.NewAppError("createPost", "api.post.create_post.channel_root_id.app_error", nil, "", http.StatusInternalServerError) + return nil, false, model.NewAppError("createPost", "api.post.create_post.channel_root_id.app_error", nil, "", http.StatusInternalServerError) } rootPost := parentPostList.Posts[post.RootId] if rootPost.RootId != "" { - return nil, model.NewAppError("createPost", "api.post.create_post.root_id.app_error", nil, "", http.StatusBadRequest) + return nil, false, model.NewAppError("createPost", "api.post.create_post.root_id.app_error", nil, "", http.StatusBadRequest) } if rootPost.Type == model.PostTypeBurnOnRead { - return nil, model.NewAppError("createPost", "api.post.create_post.burn_on_read.app_error", nil, "", http.StatusBadRequest) + return nil, false, model.NewAppError("createPost", "api.post.create_post.burn_on_read.app_error", nil, "", http.StatusBadRequest) } } post.Hashtags, _ = model.ParseHashtags(post.Message) if err = a.FillInPostProps(rctx, post, channel); err != nil { - return nil, err + return nil, false, err } // Temporary fix so old plugins don't clobber new fields in SlackAttachment struct, see MM-13088 @@ -326,7 +339,7 @@ func (a *App) CreatePost(rctx request.CTX, post *model.Post, channel *model.Chan }, plugin.MessageWillBePostedID) if rejectionError != nil { - return nil, rejectionError + return nil, false, rejectionError } } @@ -350,18 +363,18 @@ func (a *App) CreatePost(rctx request.CTX, post *model.Post, channel *model.Chan var invErr *store.ErrInvalidInput switch { case errors.As(nErr, &appErr): - return nil, appErr + return nil, false, appErr case errors.As(nErr, &invErr): - return nil, model.NewAppError("CreatePost", "app.post.save.existing.app_error", nil, "", http.StatusBadRequest).Wrap(nErr) + return nil, false, model.NewAppError("CreatePost", "app.post.save.existing.app_error", nil, "", http.StatusBadRequest).Wrap(nErr) default: - return nil, model.NewAppError("CreatePost", "app.post.save.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + return nil, false, model.NewAppError("CreatePost", "app.post.save.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } } // Update the mapping from pending post id to the actual post id, for any clients that // might be duplicating requests. if appErr := a.Srv().seenPendingPostIdsCache.SetWithExpiry(post.PendingPostId, rpost.Id, pendingPostIDsCacheTTL); appErr != nil { - return nil, model.NewAppError("CreatePost", "api.post.deduplicate_create_post.cache_error", nil, "", http.StatusInternalServerError).Wrap(appErr) + return nil, false, model.NewAppError("CreatePost", "api.post.deduplicate_create_post.cache_error", nil, "", http.StatusInternalServerError).Wrap(appErr) } if a.Metrics() != nil { @@ -413,7 +426,7 @@ func (a *App) CreatePost(rctx request.CTX, post *model.Post, channel *model.Chan mlog.String("reason", model.NotificationReasonResolvePersistentNotificationError), mlog.Err(appErr), ) - return nil, appErr + return nil, false, appErr } } @@ -437,12 +450,12 @@ func (a *App) CreatePost(rctx request.CTX, post *model.Post, channel *model.Chan a.SendEphemeralPost(rctx, post.UserId, ephemeralPost) } - rpost, err = a.SanitizePostMetadataForUser(rctx, rpost, rctx.Session().UserId) + rpost, isMemberForPreviews, err = a.SanitizePostMetadataForUser(rctx, rpost, rctx.Session().UserId) if err != nil { - return nil, err + return nil, false, err } - return rpost, nil + return rpost, isMemberForPreviews, nil } func (a *App) addPostPreviewProp(rctx request.CTX, post *model.Post) (*model.Post, error) { @@ -506,15 +519,17 @@ func (a *App) FillInPostProps(rctx request.CTX, post *model.Post, channel *model } for _, mentioned := range mentionedChannels { - if mentioned.Type == model.ChannelTypeOpen && a.HasPermissionToReadChannel(rctx, post.UserId, mentioned) { - team, err := a.Srv().Store().Team().Get(mentioned.TeamId) - if err != nil { - rctx.Logger().Warn("Failed to get team of the channel mention", mlog.String("team_id", channel.TeamId), mlog.String("channel_id", channel.Id), mlog.Err(err)) - continue - } - channelMentionsProp[mentioned.Name] = map[string]any{ - "display_name": mentioned.DisplayName, - "team_name": team.Name, + if mentioned.Type == model.ChannelTypeOpen { + if ok, _ := a.HasPermissionToReadChannel(rctx, post.UserId, mentioned); ok { + team, err := a.Srv().Store().Team().Get(mentioned.TeamId) + if err != nil { + rctx.Logger().Warn("Failed to get team of the channel mention", mlog.String("team_id", channel.TeamId), mlog.String("channel_id", channel.Id), mlog.Err(err)) + continue + } + channelMentionsProp[mentioned.Name] = map[string]any{ + "display_name": mentioned.DisplayName, + "team_name": team.Name, + } } } } @@ -527,7 +542,12 @@ func (a *App) FillInPostProps(rctx request.CTX, post *model.Post, channel *model } matched := atMentionPattern.MatchString(post.Message) - if a.Srv().License() != nil && *a.Srv().License().Features.LDAPGroups && matched && !a.HasPermissionToChannel(rctx, post.UserId, post.ChannelId, model.PermissionUseGroupMentions) { + shouldAddProp := false + if a.Srv().License() != nil && *a.Srv().License().Features.LDAPGroups && matched { + hasPermission, _ := a.HasPermissionToChannel(rctx, post.UserId, post.ChannelId, model.PermissionUseGroupMentions) + shouldAddProp = !hasPermission + } + if shouldAddProp { post.AddProp(model.PostPropsGroupHighlightDisabled, true) } @@ -627,7 +647,7 @@ func (a *App) handlePostEvents(rctx request.CTX, post *model.Post, user *model.U return nil } -func (a *App) SendEphemeralPost(rctx request.CTX, userID string, post *model.Post) *model.Post { +func (a *App) SendEphemeralPost(rctx request.CTX, userID string, post *model.Post) (*model.Post, bool) { post.Type = model.PostTypeEphemeral // fill in fields which haven't been specified which have sensible defaults @@ -646,7 +666,7 @@ func (a *App) SendEphemeralPost(rctx request.CTX, userID string, post *model.Pos post = a.PreparePostForClientWithEmbedsAndImages(rctx, post, &model.PreparePostForClientOpts{IsNewPost: true, IncludePriority: true}) post = model.AddPostActionCookies(post, a.PostActionCookieSecret()) - sanitizedPost, appErr := a.SanitizePostMetadataForUser(rctx, post, userID) + sanitizedPost, isMemberForPreviews, appErr := a.SanitizePostMetadataForUser(rctx, post, userID) if appErr != nil { rctx.Logger().Error("Failed to sanitize post metadata for user", mlog.String("user_id", userID), mlog.Err(appErr)) @@ -664,10 +684,10 @@ func (a *App) SendEphemeralPost(rctx request.CTX, userID string, post *model.Pos message.Add("post", postJSON) a.Publish(message) - return post + return post, isMemberForPreviews } -func (a *App) UpdateEphemeralPost(rctx request.CTX, userID string, post *model.Post) *model.Post { +func (a *App) UpdateEphemeralPost(rctx request.CTX, userID string, post *model.Post) (*model.Post, bool) { post.Type = model.PostTypeEphemeral post.UpdateAt = model.GetMillis() @@ -680,7 +700,7 @@ func (a *App) UpdateEphemeralPost(rctx request.CTX, userID string, post *model.P post = a.PreparePostForClientWithEmbedsAndImages(rctx, post, &model.PreparePostForClientOpts{IsNewPost: true, IncludePriority: true}) post = model.AddPostActionCookies(post, a.PostActionCookieSecret()) - sanitizedPost, appErr := a.SanitizePostMetadataForUser(rctx, post, userID) + sanitizedPost, isMemberForPreviews, appErr := a.SanitizePostMetadataForUser(rctx, post, userID) if appErr != nil { rctx.Logger().Error("Failed to sanitize post metadata for user", mlog.String("user_id", userID), mlog.Err(appErr)) @@ -698,7 +718,7 @@ func (a *App) UpdateEphemeralPost(rctx request.CTX, userID string, post *model.P message.Add("post", postJSON) a.Publish(message) - return post + return post, isMemberForPreviews } func (a *App) DeleteEphemeralPost(rctx request.CTX, userID, postID string) { @@ -719,7 +739,7 @@ func (a *App) DeleteEphemeralPost(rctx request.CTX, userID, postID string) { a.Publish(message) } -func (a *App) UpdatePost(rctx request.CTX, receivedUpdatedPost *model.Post, updatePostOptions *model.UpdatePostOptions) (*model.Post, *model.AppError) { +func (a *App) UpdatePost(rctx request.CTX, receivedUpdatedPost *model.Post, updatePostOptions *model.UpdatePostOptions) (*model.Post, bool, *model.AppError) { if updatePostOptions == nil { updatePostOptions = model.DefaultUpdatePostOptions() } @@ -732,11 +752,11 @@ func (a *App) UpdatePost(rctx request.CTX, receivedUpdatedPost *model.Post, upda var invErr *store.ErrInvalidInput switch { case errors.As(nErr, &invErr): - return nil, model.NewAppError("UpdatePost", "app.post.get.app_error", nil, "", http.StatusBadRequest).Wrap(nErr) + return nil, false, model.NewAppError("UpdatePost", "app.post.get.app_error", nil, "", http.StatusBadRequest).Wrap(nErr) case errors.As(nErr, &nfErr): - return nil, model.NewAppError("UpdatePost", "app.post.get.app_error", nil, "", http.StatusNotFound).Wrap(nErr) + return nil, false, model.NewAppError("UpdatePost", "app.post.get.app_error", nil, "", http.StatusNotFound).Wrap(nErr) default: - return nil, model.NewAppError("UpdatePost", "app.post.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + return nil, false, model.NewAppError("UpdatePost", "app.post.get.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } } oldPost := postLists.Posts[receivedUpdatedPost.Id] @@ -744,40 +764,40 @@ func (a *App) UpdatePost(rctx request.CTX, receivedUpdatedPost *model.Post, upda var appErr *model.AppError if oldPost == nil { appErr = model.NewAppError("UpdatePost", "api.post.update_post.find.app_error", nil, "id="+receivedUpdatedPost.Id, http.StatusBadRequest) - return nil, appErr + return nil, false, appErr } if oldPost.DeleteAt != 0 { appErr = model.NewAppError("UpdatePost", "api.post.update_post.permissions_details.app_error", map[string]any{"PostId": receivedUpdatedPost.Id}, "", http.StatusBadRequest) - return nil, appErr + return nil, false, appErr } if oldPost.Type == model.PostTypeBurnOnRead { - return nil, model.NewAppError("UpdatePost", "api.post.update_post.burn_on_read.app_error", nil, "", http.StatusBadRequest) + return nil, false, model.NewAppError("UpdatePost", "api.post.update_post.burn_on_read.app_error", nil, "", http.StatusBadRequest) } if oldPost.IsSystemMessage() { appErr = model.NewAppError("UpdatePost", "api.post.update_post.system_message.app_error", nil, "id="+receivedUpdatedPost.Id, http.StatusBadRequest) - return nil, appErr + return nil, false, appErr } channel, appErr := a.GetChannel(rctx, oldPost.ChannelId) if appErr != nil { - return nil, appErr + return nil, false, appErr } if channel.DeleteAt != 0 { - return nil, model.NewAppError("UpdatePost", "api.post.update_post.can_not_update_post_in_deleted.error", nil, "", http.StatusBadRequest) + return nil, false, model.NewAppError("UpdatePost", "api.post.update_post.can_not_update_post_in_deleted.error", nil, "", http.StatusBadRequest) } restrictDM, err := a.CheckIfChannelIsRestrictedDM(rctx, channel) if err != nil { - return nil, err + return nil, false, err } if restrictDM { err := model.NewAppError("UpdatePost", "api.post.update_post.can_not_update_post_in_restricted_dm.error", nil, "", http.StatusBadRequest) - return nil, err + return nil, false, err } newPost := oldPost.Clone() @@ -796,7 +816,7 @@ func (a *App) UpdatePost(rctx request.CTX, receivedUpdatedPost *model.Post, upda var fileIds []string fileIds, appErr = a.processPostFileChanges(rctx, receivedUpdatedPost, oldPost, updatePostOptions) if appErr != nil { - return nil, appErr + return nil, false, appErr } newPost.FileIds = fileIds } @@ -807,7 +827,7 @@ func (a *App) UpdatePost(rctx request.CTX, receivedUpdatedPost *model.Post, upda } if appErr = a.FillInPostProps(rctx, newPost, nil); appErr != nil { - return nil, appErr + return nil, false, appErr } if receivedUpdatedPost.IsRemote() { @@ -822,7 +842,7 @@ func (a *App) UpdatePost(rctx request.CTX, receivedUpdatedPost *model.Post, upda return newPost != nil }, plugin.MessageWillBeUpdatedID) if newPost == nil { - return nil, model.NewAppError("UpdatePost", "Post rejected by plugin. "+rejectionReason, nil, "", http.StatusBadRequest) + return nil, false, model.NewAppError("UpdatePost", "Post rejected by plugin. "+rejectionReason, nil, "", http.StatusBadRequest) } } @@ -839,9 +859,9 @@ func (a *App) UpdatePost(rctx request.CTX, receivedUpdatedPost *model.Post, upda if nErr != nil { switch { case errors.As(nErr, &appErr): - return nil, appErr + return nil, false, appErr default: - return nil, model.NewAppError("UpdatePost", "app.post.update.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + return nil, false, model.NewAppError("UpdatePost", "app.post.update.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } } @@ -865,20 +885,20 @@ func (a *App) UpdatePost(rctx request.CTX, receivedUpdatedPost *model.Post, upda rpost, nErr = a.addPostPreviewProp(rctx, rpost) if nErr != nil { - return nil, model.NewAppError("UpdatePost", "app.post.update.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) + return nil, false, model.NewAppError("UpdatePost", "app.post.update.app_error", nil, "", http.StatusInternalServerError).Wrap(nErr) } message := model.NewWebSocketEvent(model.WebsocketEventPostEdited, "", rpost.ChannelId, "", nil, "") appErr = a.publishWebsocketEventForPost(rctx, rpost, message) if appErr != nil { - return nil, appErr + return nil, false, appErr } a.invalidateCacheForChannelPosts(rpost.ChannelId) userID := rctx.Session().UserId - sanitizedPost, appErr := a.SanitizePostMetadataForUser(rctx, rpost, userID) + sanitizedPost, isMemberForPreviews, appErr := a.SanitizePostMetadataForUser(rctx, rpost, userID) if appErr != nil { mlog.Error("Failed to sanitize post metadata for user", mlog.String("user_id", userID), mlog.Err(appErr)) @@ -889,7 +909,7 @@ func (a *App) UpdatePost(rctx request.CTX, receivedUpdatedPost *model.Post, upda } rpost = sanitizedPost - return rpost, nil + return rpost, isMemberForPreviews, nil } func (a *App) publishWebsocketEventForPost(rctx request.CTX, post *model.Post, message *model.WebSocketEvent) *model.AppError { @@ -1013,7 +1033,9 @@ func (a *App) setupBroadcastHookForPermalink(rctx request.CTX, post *model.Post, // In case the user does have permission to read, we set the metadata back. // Note that this is the return value to the post creator, and has nothing to do // with the content of the websocket broadcast to that user or any other. - if a.HasPermissionToReadChannel(rctx, post.UserId, permalinkPreviewedChannel) { + // We also don't check the membership for the previewed post, since + // the broadcast handler will create the audit events if needed. + if ok, _ := a.HasPermissionToReadChannel(rctx, post.UserId, permalinkPreviewedChannel); ok { post.AddProp(model.PostPropsPreviewedPost, previewProp) post.Metadata.Embeds = append(post.Metadata.Embeds, &model.PostEmbed{Type: model.PostEmbedPermalink, Data: permalinkPreviewedPost}) } @@ -1038,53 +1060,53 @@ func (a *App) processBroadcastHookForBurnOnRead(rctx request.CTX, postJSON strin return nil } -func (a *App) PatchPost(rctx request.CTX, postID string, patch *model.PostPatch, patchPostOptions *model.UpdatePostOptions) (*model.Post, *model.AppError) { +func (a *App) PatchPost(rctx request.CTX, postID string, patch *model.PostPatch, patchPostOptions *model.UpdatePostOptions) (*model.Post, bool, *model.AppError) { if patchPostOptions == nil { patchPostOptions = model.DefaultUpdatePostOptions() } post, err := a.GetSinglePost(rctx, postID, false) if err != nil { - return nil, err + return nil, false, err } // only allow to update the pinned status of burn-on-read posts if the status is different if post.Type == model.PostTypeBurnOnRead { - return nil, model.NewAppError("PatchPost", "api.post.patch_post.can_not_update_burn_on_read_post.error", nil, "", http.StatusBadRequest) + return nil, false, model.NewAppError("PatchPost", "api.post.patch_post.can_not_update_burn_on_read_post.error", nil, "", http.StatusBadRequest) } channel, err := a.GetChannel(rctx, post.ChannelId) if err != nil { - return nil, err + return nil, false, err } if channel.DeleteAt != 0 { err = model.NewAppError("PatchPost", "api.post.patch_post.can_not_update_post_in_deleted.error", nil, "", http.StatusBadRequest) - return nil, err + return nil, false, err } restrictDM, err := a.CheckIfChannelIsRestrictedDM(rctx, channel) if err != nil { - return nil, err + return nil, false, err } if restrictDM { - return nil, model.NewAppError("PatchPost", "api.post.patch_post.can_not_update_post_in_restricted_dm.error", nil, "", http.StatusBadRequest) + return nil, false, model.NewAppError("PatchPost", "api.post.patch_post.can_not_update_post_in_restricted_dm.error", nil, "", http.StatusBadRequest) } - if !a.HasPermissionToChannel(rctx, post.UserId, post.ChannelId, model.PermissionUseChannelMentions) { + if ok, _ := a.HasPermissionToChannel(rctx, post.UserId, post.ChannelId, model.PermissionUseChannelMentions); !ok { patch.DisableMentionHighlights() } post.Patch(patch) patchPostOptions.SafeUpdate = false - updatedPost, err := a.UpdatePost(rctx, post, patchPostOptions) + updatedPost, isMemberForPreviews, err := a.UpdatePost(rctx, post, patchPostOptions) if err != nil { - return nil, err + return nil, false, err } - return updatedPost, nil + return updatedPost, isMemberForPreviews, nil } func (a *App) GetPostsPage(rctx request.CTX, options model.GetPostsOptions) (*model.PostList, *model.AppError) { @@ -1885,12 +1907,12 @@ func (a *App) SearchPostsInTeam(teamID string, paramsList []*model.SearchParams) }) } -func (a *App) SearchPostsForUser(rctx request.CTX, terms string, userID string, teamID string, isOrSearch bool, includeDeletedChannels bool, timeZoneOffset int, page, perPage int) (*model.PostSearchResults, *model.AppError) { +func (a *App) SearchPostsForUser(rctx request.CTX, terms string, userID string, teamID string, isOrSearch bool, includeDeletedChannels bool, timeZoneOffset int, page, perPage int) (*model.PostSearchResults, bool, *model.AppError) { var postSearchResults *model.PostSearchResults paramsList := model.ParseSearchParams(strings.TrimSpace(terms), timeZoneOffset) if !*a.Config().ServiceSettings.EnablePostSearch { - return nil, model.NewAppError("SearchPostsForUser", "store.sql_post.search.disabled", nil, fmt.Sprintf("teamId=%v userId=%v", teamID, userID), http.StatusNotImplemented) + return nil, false, model.NewAppError("SearchPostsForUser", "store.sql_post.search.disabled", nil, fmt.Sprintf("teamId=%v userId=%v", teamID, userID), http.StatusNotImplemented) } finalParamsList := []*model.SearchParams{} @@ -1917,7 +1939,7 @@ func (a *App) SearchPostsForUser(rctx request.CTX, terms string, userID string, // If the processed search params are empty, return empty search results. if len(finalParamsList) == 0 { - return model.MakePostSearchResults(model.NewPostList(), nil), nil + return model.MakePostSearchResults(model.NewPostList(), nil), true, nil } postSearchResults, err := a.Srv().Store().Post().SearchPostsForUser(rctx, finalParamsList, userID, teamID, page, perPage) @@ -1925,21 +1947,85 @@ func (a *App) SearchPostsForUser(rctx request.CTX, terms string, userID string, var appErr *model.AppError switch { case errors.As(err, &appErr): - return nil, appErr + return nil, false, appErr default: - return nil, model.NewAppError("SearchPostsForUser", "app.post.search.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return nil, false, model.NewAppError("SearchPostsForUser", "app.post.search.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } } if appErr := a.filterInaccessiblePosts(postSearchResults.PostList, filterPostOptions{assumeSortedCreatedAt: true}); appErr != nil { - return nil, appErr + return nil, false, appErr + } + + allPostHaveMembership, appErr := a.FilterPostsByChannelPermissions(rctx, postSearchResults.PostList, userID) + if appErr != nil { + return nil, false, appErr } if appErr := a.filterBurnOnReadPosts(postSearchResults.PostList); appErr != nil { - return nil, appErr + return nil, false, appErr } - return postSearchResults, nil + return postSearchResults, allPostHaveMembership, nil +} + +func (a *App) FilterPostsByChannelPermissions(rctx request.CTX, postList *model.PostList, userID string) (bool, *model.AppError) { + if postList == nil || postList.Posts == nil || len(postList.Posts) == 0 { + return true, nil // On an empty post list, we consider all posts as having membership + } + + channels := make(map[string]*model.Channel) + for _, post := range postList.Posts { + if post.ChannelId != "" { + channels[post.ChannelId] = nil + } + } + + if len(channels) > 0 { + channelIDs := slices.Collect(maps.Keys(channels)) + channelList, err := a.GetChannels(rctx, channelIDs) + if err != nil && err.StatusCode != http.StatusNotFound { + return false, err + } + for _, channel := range channelList { + channels[channel.Id] = channel + } + } + + channelReadPermission := make(map[string]bool) + filteredPosts := make(map[string]*model.Post) + filteredOrder := []string{} + allPostHaveMembership := true + + for _, postID := range postList.Order { + post, ok := postList.Posts[postID] + if !ok { + continue + } + + if _, ok := channelReadPermission[post.ChannelId]; !ok { + channel := channels[post.ChannelId] + allowed := false + isMember := true + if channel != nil { + allowed, isMember = a.HasPermissionToReadChannel(rctx, userID, channel) + } + channelReadPermission[post.ChannelId] = allowed + if allowed { + allPostHaveMembership = allPostHaveMembership && isMember + } + } + + if channelReadPermission[post.ChannelId] { + filteredPosts[postID] = post + filteredOrder = append(filteredOrder, postID) + } + } + + postList.Posts = filteredPosts + postList.Order = filteredOrder + + return allPostHaveMembership, nil } func (a *App) GetFileInfosForPostWithMigration(rctx request.CTX, postID string, includeDeleted bool) ([]*model.FileInfo, *model.AppError) { @@ -2292,28 +2378,29 @@ func (a *App) GetThreadMembershipsForUser(userID, teamID string) ([]*model.Threa return a.Srv().Store().Thread().GetMembershipsForUser(userID, teamID) } -func (a *App) GetPostIfAuthorized(rctx request.CTX, postID string, session *model.Session, includeDeleted bool) (*model.Post, *model.AppError) { +func (a *App) GetPostIfAuthorized(rctx request.CTX, postID string, session *model.Session, includeDeleted bool) (*model.Post, *model.AppError, bool) { post, err := a.GetSinglePost(rctx, postID, includeDeleted) if err != nil { - return nil, err + return nil, err, false } channel, err := a.GetChannel(rctx, post.ChannelId) if err != nil { - return nil, err + return nil, err, false } - if !a.SessionHasPermissionToReadChannel(rctx, *session, channel) { + ok, isMember := a.SessionHasPermissionToReadChannel(rctx, *session, channel) + if !ok { if channel.Type == model.ChannelTypeOpen && !*a.Config().ComplianceSettings.Enable { if !a.SessionHasPermissionToTeam(*session, channel.TeamId, model.PermissionReadPublicChannel) { - return nil, model.MakePermissionError(session, []*model.Permission{model.PermissionReadPublicChannel}) + return nil, model.MakePermissionError(session, []*model.Permission{model.PermissionReadPublicChannel}), false } } else { - return nil, model.MakePermissionError(session, []*model.Permission{model.PermissionReadChannelContent}) + return nil, model.MakePermissionError(session, []*model.Permission{model.PermissionReadChannelContent}), false } } - return post, nil + return post, nil, isMember } // GetPostsByIds response bool value indicates, if the post is inaccessible due to cloud plan's limit. @@ -2496,89 +2583,19 @@ func (a *App) CheckPostReminders(rctx request.CTX) { }, } - if _, err := a.CreatePost(request.EmptyContext(a.Log()), dm, ch, model.CreatePostFlags{SetOnline: true}); err != nil { + if _, _, err := a.CreatePost(request.EmptyContext(a.Log()), dm, ch, model.CreatePostFlags{SetOnline: true}); err != nil { rctx.Logger().Error("Failed to post reminder message", mlog.Err(err)) } } } } -func (a *App) GetPostInfo(rctx request.CTX, postID string) (*model.PostInfo, *model.AppError) { - userID := rctx.Session().UserId - post, appErr := a.GetSinglePost(rctx, postID, false) - if appErr != nil { - return nil, appErr - } - - channel, appErr := a.GetChannel(rctx, post.ChannelId) - if appErr != nil { - return nil, appErr - } - - notFoundError := model.NewAppError("GetPostInfo", "app.post.get.app_error", nil, "", http.StatusNotFound) - - var team *model.Team - hasPermissionToAccessTeam := false - if channel.TeamId != "" { - team, appErr = a.GetTeam(channel.TeamId) - if appErr != nil { - return nil, appErr - } - - teamMember, appErr := a.GetTeamMember(rctx, channel.TeamId, userID) - if appErr != nil && appErr.StatusCode != http.StatusNotFound { - return nil, appErr - } - - if appErr == nil { - if teamMember.DeleteAt == 0 { - hasPermissionToAccessTeam = true - } - } - - if !hasPermissionToAccessTeam { - if team.AllowOpenInvite { - hasPermissionToAccessTeam = a.HasPermissionToTeam(rctx, userID, team.Id, model.PermissionJoinPublicTeams) - } else { - hasPermissionToAccessTeam = a.HasPermissionToTeam(rctx, userID, team.Id, model.PermissionJoinPrivateTeams) - } - } - } else { - // This happens in case of DMs and GMs. - hasPermissionToAccessTeam = true - } - - if !hasPermissionToAccessTeam { - return nil, notFoundError - } - - hasPermissionToAccessChannel := false - - _, channelMemberErr := a.GetChannelMember(rctx, channel.Id, userID) - - if channelMemberErr == nil { - hasPermissionToAccessChannel = true - } - - if !hasPermissionToAccessChannel { - if channel.Type == model.ChannelTypeOpen { - hasPermissionToAccessChannel = true - } else if channel.Type == model.ChannelTypePrivate { - hasPermissionToAccessChannel = a.HasPermissionToChannel(rctx, userID, channel.Id, model.PermissionManagePrivateChannelMembers) - } else if channel.Type == model.ChannelTypeDirect || channel.Type == model.ChannelTypeGroup { - hasPermissionToAccessChannel = a.HasPermissionToChannel(rctx, userID, channel.Id, model.PermissionReadChannelContent) - } - } - - if !hasPermissionToAccessChannel { - return nil, notFoundError - } - +func (a *App) GetPostInfo(rctx request.CTX, postID string, channel *model.Channel, team *model.Team, userID string, hasJoinedChannel bool) (*model.PostInfo, *model.AppError) { info := model.PostInfo{ ChannelId: channel.Id, ChannelType: channel.Type, ChannelDisplayName: channel.DisplayName, - HasJoinedChannel: channelMemberErr == nil, + HasJoinedChannel: hasJoinedChannel, } if team != nil { teamMember, teamMemberErr := a.GetTeamMember(rctx, team.Id, userID) @@ -2683,7 +2700,7 @@ func (a *App) ValidateMoveOrCopy(rctx request.CTX, wpl *model.WranglerPostList, return nil } -func (a *App) CopyWranglerPostlist(rctx request.CTX, wpl *model.WranglerPostList, targetChannel *model.Channel) (*model.Post, *model.AppError) { +func (a *App) CopyWranglerPostlist(rctx request.CTX, wpl *model.WranglerPostList, targetChannel *model.Channel) (*model.Post, bool, *model.AppError) { var appErr *model.AppError var newRootPost *model.Post @@ -2703,15 +2720,15 @@ func (a *App) CopyWranglerPostlist(rctx request.CTX, wpl *model.WranglerPostList for _, fileID := range post.FileIds { oldFileInfo, appErr = a.GetFileInfo(rctx, fileID) if appErr != nil { - return nil, appErr + return nil, false, appErr } fileBytes, appErr = a.GetFile(rctx, fileID) if appErr != nil { - return nil, appErr + return nil, false, appErr } newFileInfo, appErr = a.UploadFile(rctx, fileBytes, targetChannel.Id, oldFileInfo.Name) if appErr != nil { - return nil, appErr + return nil, false, appErr } newFileIDs = append(newFileIDs, newFileInfo.Id) @@ -2721,6 +2738,8 @@ func (a *App) CopyWranglerPostlist(rctx request.CTX, wpl *model.WranglerPostList } } + var isMemberForPreviews bool + for i, post := range wpl.Posts { var reactions []*model.Reaction @@ -2736,16 +2755,16 @@ func (a *App) CopyWranglerPostlist(rctx request.CTX, wpl *model.WranglerPostList newPost.ChannelId = targetChannel.Id if i == 0 { - newPost, appErr = a.CreatePost(rctx, newPost, targetChannel, model.CreatePostFlags{}) + newPost, isMemberForPreviews, appErr = a.CreatePost(rctx, newPost, targetChannel, model.CreatePostFlags{}) if appErr != nil { - return nil, appErr + return nil, false, appErr } newRootPost = newPost.Clone() } else { newPost.RootId = newRootPost.Id - newPost, appErr = a.CreatePost(rctx, newPost, targetChannel, model.CreatePostFlags{}) + newPost, _, appErr = a.CreatePost(rctx, newPost, targetChannel, model.CreatePostFlags{}) if appErr != nil { - return nil, appErr + return nil, false, appErr } } @@ -2759,7 +2778,7 @@ func (a *App) CopyWranglerPostlist(rctx request.CTX, wpl *model.WranglerPostList } } - return newRootPost, nil + return newRootPost, isMemberForPreviews, nil } func (a *App) MoveThread(rctx request.CTX, postID string, sourceChannelID, channelID string, user *model.User) *model.AppError { @@ -2805,7 +2824,7 @@ func (a *App) MoveThread(rctx request.CTX, postID string, sourceChannelID, chann // To simulate the move, we first copy the original messages(s) to the // new channel and later delete the original messages(s). - newRootPost, appErr := a.CopyWranglerPostlist(rctx, wpl, targetChannel) + newRootPost, _, appErr := a.CopyWranglerPostlist(rctx, wpl, targetChannel) if appErr != nil { return appErr } @@ -2818,7 +2837,7 @@ func (a *App) MoveThread(rctx request.CTX, postID string, sourceChannelID, chann ephemeralPostProps := model.StringInterface{ "TranslationID": "app.post.move_thread.from_another_channel", } - _, appErr = a.CreatePost(rctx, &model.Post{ + _, _, appErr = a.CreatePost(rctx, &model.Post{ UserId: user.Id, Type: model.PostTypeWrangler, RootId: newRootPost.Id, @@ -2866,7 +2885,7 @@ func (a *App) MoveThread(rctx request.CTX, postID string, sourceChannelID, chann ephemeralPostProps["NumMessages"] = wpl.NumPosts() - _, appErr = a.CreatePost(rctx, &model.Post{ + _, _, appErr = a.CreatePost(rctx, &model.Post{ UserId: user.Id, Type: model.PostTypeWrangler, ChannelId: originalChannel.Id, @@ -3002,7 +3021,8 @@ func (a *App) SendTestMessage(rctx request.CTX, userID string) (*model.Post, *mo UserId: bot.UserId, } - post, err = a.CreatePost(rctx, post, channel, model.CreatePostFlags{ForceNotification: true}) + // We don't check the preview membership because the test message does not send a link to a different post. + post, _, err = a.CreatePost(rctx, post, channel, model.CreatePostFlags{ForceNotification: true}) if err != nil { return nil, model.NewAppError("SendTestMessage", "app.notifications.send_test_message.errors.create_post", nil, "", http.StatusInternalServerError).Wrap(err) } diff --git a/server/channels/app/post_acknowledgements_test.go b/server/channels/app/post_acknowledgements_test.go index b40b00cc240..09e781c0015 100644 --- a/server/channels/app/post_acknowledgements_test.go +++ b/server/channels/app/post_acknowledgements_test.go @@ -23,7 +23,7 @@ func testSaveAcknowledgementForPost(t *testing.T) { th := Setup(t).InitBasic(t) t.Run("save acknowledgment for post should save acknowledgement", func(t *testing.T) { - post, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + post, _, err := th.App.CreatePostAsUser(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "message", @@ -40,7 +40,7 @@ func testSaveAcknowledgementForPost(t *testing.T) { }) t.Run("saving acknowledgment should update the post's update_at", func(t *testing.T) { - post, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + post, _, err := th.App.CreatePostAsUser(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "message", @@ -64,7 +64,7 @@ func testDeleteAcknowledgementForPost(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic(t) - post, err1 := th.App.CreatePostAsUser(th.Context, &model.Post{ + post, _, err1 := th.App.CreatePostAsUser(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, CreateAt: model.GetMillis(), @@ -137,7 +137,7 @@ func testGetAcknowledgementsForPostList(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic(t) - p1, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + p1, _, err := th.App.CreatePostAsUser(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, CreateAt: model.GetMillis(), @@ -145,7 +145,7 @@ func testGetAcknowledgementsForPostList(t *testing.T) { }, "", true) require.Nil(t, err) - p2, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + p2, _, err := th.App.CreatePostAsUser(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, CreateAt: model.GetMillis(), @@ -153,7 +153,7 @@ func testGetAcknowledgementsForPostList(t *testing.T) { }, "", true) require.Nil(t, err) - p3, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + p3, _, err := th.App.CreatePostAsUser(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, CreateAt: model.GetMillis(), diff --git a/server/channels/app/post_metadata.go b/server/channels/app/post_metadata.go index ef2cfc4a7e2..a882230f98e 100644 --- a/server/channels/app/post_metadata.go +++ b/server/channels/app/post_metadata.go @@ -247,50 +247,48 @@ func removeEmbeddedPostsFromMetadata(post *model.Post) { post.Metadata.Embeds = newEmbeds } -func (a *App) sanitizePostMetadataForUserAndChannel(rctx request.CTX, post *model.Post, previewedPost *model.PreviewPost, previewedChannel *model.Channel, userID string) *model.Post { - if post.Metadata == nil || len(post.Metadata.Embeds) == 0 || previewedPost == nil { - return post - } - - if previewedChannel != nil && !a.HasPermissionToReadChannel(rctx, userID, previewedChannel) { - removePermalinkMetadataFromPost(post) - } - - return post -} - -func (a *App) SanitizePostMetadataForUser(rctx request.CTX, post *model.Post, userID string) (*model.Post, *model.AppError) { +func (a *App) SanitizePostMetadataForUser(rctx request.CTX, post *model.Post, userID string) (*model.Post, bool, *model.AppError) { if post.Metadata == nil || len(post.Metadata.Embeds) == 0 { - return post, nil + return post, true, nil } previewPost := post.GetPreviewPost() if previewPost == nil { - return post, nil + return post, true, nil } previewedChannel, err := a.GetChannel(rctx, previewPost.Post.ChannelId) if err != nil { - return nil, err + return nil, false, err } - if previewedChannel != nil && !a.HasPermissionToReadChannel(rctx, userID, previewedChannel) { - removePermalinkMetadataFromPost(post) + isMember := true + + if previewedChannel != nil { + var hasPermission bool + hasPermission, isMember = a.HasPermissionToReadChannel(rctx, userID, previewedChannel) + if !hasPermission { + removePermalinkMetadataFromPost(post) + // Since we remove the permalink metadata, we return true + isMember = true + } } - return post, nil + return post, isMember, nil } -func (a *App) SanitizePostListMetadataForUser(rctx request.CTX, postList *model.PostList, userID string) (*model.PostList, *model.AppError) { +func (a *App) SanitizePostListMetadataForUser(rctx request.CTX, postList *model.PostList, userID string) (*model.PostList, bool, *model.AppError) { clonedPostList := postList.Clone() + allPreviewsHaveMembership := true for postID, post := range clonedPostList.Posts { - sanitizedPost, err := a.SanitizePostMetadataForUser(rctx, post, userID) + sanitizedPost, isMember, err := a.SanitizePostMetadataForUser(rctx, post, userID) if err != nil { - return nil, err + return nil, false, err } clonedPostList.Posts[postID] = sanitizedPost + allPreviewsHaveMembership = allPreviewsHaveMembership && isMember } - return clonedPostList, nil + return clonedPostList, allPreviewsHaveMembership, nil } func (a *App) getFileMetadataForPost(rctx request.CTX, post *model.Post, fromMaster, includeDeleted bool) ([]*model.FileInfo, int64, *model.AppError) { diff --git a/server/channels/app/post_metadata_test.go b/server/channels/app/post_metadata_test.go index 5d35ef264cf..41680ba279e 100644 --- a/server/channels/app/post_metadata_test.go +++ b/server/channels/app/post_metadata_test.go @@ -189,7 +189,7 @@ func TestPreparePostForClient(t *testing.T) { fileInfo.ChannelId = th.BasicChannel.Id require.Nil(t, err) - post, err := th.App.CreatePost(th.Context, &model.Post{ + post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, FileIds: []string{fileInfo.Id}, @@ -216,7 +216,7 @@ func TestPreparePostForClient(t *testing.T) { emoji := th.CreateEmoji(t) - post, err := th.App.CreatePost(th.Context, &model.Post{ + post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: ":" + emoji.Name + ": :taco:", @@ -259,7 +259,7 @@ func TestPreparePostForClient(t *testing.T) { emoji3 := th.CreateEmoji(t) emoji4 := th.CreateEmoji(t) - post, err := th.App.CreatePost(th.Context, &model.Post{ + post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: ":" + emoji3.Name + ": :taco:", @@ -299,7 +299,7 @@ func TestPreparePostForClient(t *testing.T) { *cfg.ServiceSettings.EnablePostIconOverride = override }) - post, err := th.App.CreatePost(th.Context, &model.Post{ + post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "Test", @@ -355,7 +355,7 @@ func TestPreparePostForClient(t *testing.T) { t.Run("markdown image dimensions", func(t *testing.T) { th := setup(t) - post, err := th.App.CreatePost(th.Context, &model.Post{ + post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: fmt.Sprintf("This is ![our logo](%s/test-image2.png) and ![our icon](%s/test-image1.png)", server.URL, server.URL), @@ -383,7 +383,7 @@ func TestPreparePostForClient(t *testing.T) { t.Run("post props has invalid fields", func(t *testing.T) { th := setup(t) - post, err := th.App.CreatePost(th.Context, &model.Post{ + post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "some post", @@ -413,7 +413,7 @@ func TestPreparePostForClient(t *testing.T) { t.Run("image embed", func(t *testing.T) { th := setup(t) - post, err := th.App.CreatePost(th.Context, &model.Post{ + post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: `This is our logo: ` + server.URL + `/test-image2.png @@ -448,7 +448,7 @@ func TestPreparePostForClient(t *testing.T) { t.Run("opengraph embed", func(t *testing.T) { th := setup(t) - post, err := th.App.CreatePost(th.Context, &model.Post{ + post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: `This is our web page: ` + server.URL, @@ -522,7 +522,7 @@ func TestPreparePostForClient(t *testing.T) { } prepost.AddProp(model.PostPropsUnsafeLinks, "true") - post, err := th.App.CreatePost(th.Context, prepost, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + post, _, err := th.App.CreatePost(th.Context, prepost, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{}) @@ -538,7 +538,7 @@ func TestPreparePostForClient(t *testing.T) { Message: `Bla bla bla: ` + fmt.Sprintf(tc.link, server.URL), } - post, err := th.App.CreatePost(th.Context, prepost, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + post, _, err := th.App.CreatePost(th.Context, prepost, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) clientPost := th.App.PreparePostForClient(th.Context, post, &model.PreparePostForClientOpts{}) @@ -553,7 +553,7 @@ func TestPreparePostForClient(t *testing.T) { t.Run("message attachment embed", func(t *testing.T) { th := setup(t) - post, err := th.App.CreatePost(th.Context, &model.Post{ + post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Props: map[string]any{ @@ -593,7 +593,7 @@ func TestPreparePostForClient(t *testing.T) { fileInfo, err := th.App.DoUploadFile(th.Context, time.Now(), th.BasicTeam.Id, th.BasicChannel.Id, th.BasicUser.Id, "test.txt", []byte("test"), true) require.Nil(t, err) - post, err := th.App.CreatePost(th.Context, &model.Post{ + post, _, err := th.App.CreatePost(th.Context, &model.Post{ Message: "test", FileIds: []string{fileInfo.Id}, UserId: th.BasicUser.Id, @@ -627,7 +627,7 @@ func TestPreparePostForClient(t *testing.T) { th.Context.Session().UserId = th.BasicUser.Id - referencedPost, err := th.App.CreatePost(th.Context, &model.Post{ + referencedPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "hello world", @@ -637,7 +637,7 @@ func TestPreparePostForClient(t *testing.T) { link := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id) - previewPost, err := th.App.CreatePost(th.Context, &model.Post{ + previewPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: link, @@ -684,7 +684,7 @@ func TestPreparePostForClient(t *testing.T) { for _, testCase := range testCases { t.Run(testCase.Description, func(t *testing.T) { - referencedPost, err := th.App.CreatePost(th.Context, &model.Post{ + referencedPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: testCase.Channel.Id, Message: "hello world", @@ -694,7 +694,7 @@ func TestPreparePostForClient(t *testing.T) { link := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id) - previewPost, err := th.App.CreatePost(th.Context, &model.Post{ + previewPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: link, @@ -721,7 +721,7 @@ func TestPreparePostForClient(t *testing.T) { th.Context.Session().UserId = th.BasicUser.Id - referencedPost, err := th.App.CreatePost(th.Context, &model.Post{ + referencedPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: `This is our logo: ` + server.URL + `/test-image2.png`, @@ -731,7 +731,7 @@ func TestPreparePostForClient(t *testing.T) { link := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id) - previewPost, err := th.App.CreatePost(th.Context, &model.Post{ + previewPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: link, @@ -757,7 +757,7 @@ func TestPreparePostForClient(t *testing.T) { th.Context.Session().UserId = th.BasicUser.Id - nestedPermalinkPost, err := th.App.CreatePost(th.Context, &model.Post{ + nestedPermalinkPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: `This is our logo: ` + server.URL + `/test-image2.png`, @@ -767,7 +767,7 @@ func TestPreparePostForClient(t *testing.T) { nestedLink := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, nestedPermalinkPost.Id) - referencedPost, err := th.App.CreatePost(th.Context, &model.Post{ + referencedPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: nestedLink, @@ -777,7 +777,7 @@ func TestPreparePostForClient(t *testing.T) { link := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id) - previewPost, err := th.App.CreatePost(th.Context, &model.Post{ + previewPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: link, @@ -803,7 +803,7 @@ func TestPreparePostForClient(t *testing.T) { th.Context.Session().UserId = th.BasicUser.Id - referencedPost, err := th.App.CreatePost(th.Context, &model.Post{ + referencedPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "hello world", @@ -812,7 +812,7 @@ func TestPreparePostForClient(t *testing.T) { link := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id) - previewPost, err := th.App.CreatePost(th.Context, &model.Post{ + previewPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: link, @@ -927,7 +927,7 @@ func testProxyOpenGraphImage(t *testing.T, th *TestHelper, shouldProxy bool) { serverURL = server.URL defer server.Close() - post, err := th.App.CreatePost(th.Context, &model.Post{ + post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: `This is our web page: ` + server.URL, @@ -2909,112 +2909,6 @@ func TestContainsPermalink(t *testing.T) { } } -func TestSanitizePostMetadataForUserAndChannel(t *testing.T) { - mainHelper.Parallel(t) - th := Setup(t).InitBasic(t) - - enableLinkPreviews := *th.App.Config().ServiceSettings.EnableLinkPreviews - siteURL := *th.App.Config().ServiceSettings.SiteURL - defer func() { - th.App.UpdateConfig(func(cfg *model.Config) { - cfg.ServiceSettings.EnableLinkPreviews = &enableLinkPreviews - cfg.ServiceSettings.SiteURL = &siteURL - }) - }() - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.EnableLinkPreviews = true - *cfg.ServiceSettings.SiteURL = "http://mymattermost.com" - }) - - t.Run("should not preview for users with no access to the channel", func(t *testing.T) { - directChannel, err := th.App.createDirectChannel(th.Context, th.BasicUser.Id, th.BasicUser2.Id) - assert.Nil(t, err) - - userID := model.NewId() - post := &model.Post{ - Id: userID, - Metadata: &model.PostMetadata{ - Embeds: []*model.PostEmbed{ - { - Type: model.PostEmbedPermalink, - Data: &model.PreviewPost{ - PostID: "permalink_post_id", - Post: &model.Post{ - Id: "permalink_post_id", - Message: "permalink post message", - ChannelId: directChannel.Id, - }, - }, - }, - }, - }, - } - - previewedPost := model.NewPreviewPost(post, th.BasicTeam, directChannel) - - actual := th.App.sanitizePostMetadataForUserAndChannel(th.Context, post, previewedPost, directChannel, th.BasicUser2.Id) - assert.NotNil(t, actual.Metadata.Embeds[0].Data) - - guestID := model.NewId() - guest := &model.User{ - Email: "success+" + guestID + "@simulator.amazonses.com", - Username: "un_" + guestID, - Nickname: "nn_" + guestID, - Password: "Password1", - EmailVerified: true, - } - guest, appErr := th.App.CreateGuest(th.Context, guest) - require.Nil(t, appErr) - - actual = th.App.sanitizePostMetadataForUserAndChannel(th.Context, post, previewedPost, directChannel, guest.Id) - assert.Len(t, actual.Metadata.Embeds, 0) - }) - - t.Run("channel previews always work for archived channels", func(t *testing.T) { - publicChannel, err := th.App.CreateChannel(th.Context, &model.Channel{ - Name: model.NewId(), - Type: model.ChannelTypeOpen, - TeamId: th.BasicTeam.Id, - CreatorId: th.SystemAdminUser.Id, - }, true) - - require.Nil(t, err) - require.NotEmpty(t, publicChannel.Id) - - err = th.App.DeleteChannel(th.Context, publicChannel, th.SystemAdminUser.Id) - require.Nil(t, err) - - publicChannel, err = th.App.GetChannel(th.Context, publicChannel.Id) - require.Nil(t, err) - require.NotEmpty(t, publicChannel.Id) - require.NotEqual(t, 0, publicChannel.DeleteAt) - - post := &model.Post{ - Id: th.BasicUser.Id, - Metadata: &model.PostMetadata{ - Embeds: []*model.PostEmbed{ - { - Type: model.PostEmbedPermalink, - Data: &model.PreviewPost{ - PostID: "permalink_post_id", - Post: &model.Post{ - Id: "permalink_post_id", - Message: "permalink post message", - ChannelId: publicChannel.Id, - }, - }, - }, - }, - }, - } - - previewedPost := model.NewPreviewPost(post, th.BasicTeam, publicChannel) - - actual := th.App.sanitizePostMetadataForUserAndChannel(th.Context, post, previewedPost, publicChannel, th.BasicUser.Id) - assert.NotNil(t, actual.Metadata.Embeds[0].Data) - }) -} - func TestSanitizePostMetaDataForAudit(t *testing.T) { mainHelper.Parallel(t) th := Setup(t).InitBasic(t) @@ -3025,7 +2919,7 @@ func TestSanitizePostMetaDataForAudit(t *testing.T) { th.Context.Session().UserId = th.BasicUser.Id - referencedPost, err := th.App.CreatePost(th.Context, &model.Post{ + referencedPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "hello world", @@ -3035,7 +2929,7 @@ func TestSanitizePostMetaDataForAudit(t *testing.T) { link := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id) - previewPost, err := th.App.CreatePost(th.Context, &model.Post{ + previewPost, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: link, @@ -3127,15 +3021,16 @@ func TestSanitizePostMetadataForUser(t *testing.T) { }, } - sanitizedPost, err := th.App.SanitizePostMetadataForUser(th.Context, post, th.BasicUser.Id) + sanitizedPost, isMemberForPreviews, err := th.App.SanitizePostMetadataForUser(th.Context, post, th.BasicUser.Id) require.Nil(t, err) require.NotNil(t, sanitizedPost) require.Equal(t, 1, len(sanitizedPost.Metadata.Embeds)) require.Equal(t, model.PostEmbedLink, sanitizedPost.Metadata.Embeds[0].Type) + require.True(t, isMemberForPreviews) }) - t.Run("should remove embeds for archived channels if the config does not allow it", func(t *testing.T) { + t.Run("should not remove embeds for archived channels", func(t *testing.T) { publicChannel, err := th.App.CreateChannel(th.Context, &model.Channel{ Name: model.NewId(), Type: model.ChannelTypeOpen, @@ -3178,12 +3073,13 @@ func TestSanitizePostMetadataForUser(t *testing.T) { }, } - sanitizedPost, err := th.App.SanitizePostMetadataForUser(th.Context, post, th.BasicUser.Id) + sanitizedPost, isMemberForPreviews, err := th.App.SanitizePostMetadataForUser(th.Context, post, th.BasicUser.Id) require.Nil(t, err) require.NotNil(t, sanitizedPost) require.Equal(t, 2, len(sanitizedPost.Metadata.Embeds)) require.Equal(t, model.PostEmbedPermalink, sanitizedPost.Metadata.Embeds[0].Type) + require.False(t, isMemberForPreviews) }) } diff --git a/server/channels/app/post_permission_utils.go b/server/channels/app/post_permission_utils.go index 31f6b2fce06..6bc5c5084cf 100644 --- a/server/channels/app/post_permission_utils.go +++ b/server/channels/app/post_permission_utils.go @@ -99,7 +99,7 @@ func postHardenedModeCheck(hardenedModeEnabled, isIntegration bool, props model. func userCreatePostPermissionCheckWithApp(rctx request.CTX, a *App, userId, channelId string) *model.AppError { hasPermission := false - if a.HasPermissionToChannel(rctx, userId, channelId, model.PermissionCreatePost) { + if ok, _ := a.HasPermissionToChannel(rctx, userId, channelId, model.PermissionCreatePost); ok { hasPermission = true } else if channel, err := a.GetChannel(rctx, channelId); err == nil { // Temporary permission check method until advanced permissions, please do not copy diff --git a/server/channels/app/post_persistent_notification_test.go b/server/channels/app/post_persistent_notification_test.go index e21522ffba8..e60dcab3bfb 100644 --- a/server/channels/app/post_persistent_notification_test.go +++ b/server/channels/app/post_persistent_notification_test.go @@ -212,7 +212,7 @@ func TestSendPersistentNotifications(t *testing.T) { }, }, } - _, appErr = th.App.CreatePost(th.Context, p1, th.BasicChannel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, p1, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) err := th.App.SendPersistentNotifications() @@ -256,7 +256,7 @@ func TestSendPersistentNotificationsBotSender(t *testing.T) { // Simulate old timestamp so persistent notifications are sent right away CreateAt: time.Now().Add(-5 * time.Minute).UnixMilli(), } - post, appErr = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) + post, _, appErr = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) assert.EventuallyWithT(t, func(c *assert.CollectT) { @@ -307,7 +307,7 @@ func TestSendPersistentNotificationsBotSenderNotInChannel(t *testing.T) { }, CreateAt: time.Now().Add(-5 * time.Minute).UnixMilli(), } - post, appErr = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + post, _, appErr = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) assert.EventuallyWithT(t, func(c *assert.CollectT) { diff --git a/server/channels/app/post_restore.go b/server/channels/app/post_restore.go index 3d699897144..678bcbeca5e 100644 --- a/server/channels/app/post_restore.go +++ b/server/channels/app/post_restore.go @@ -13,7 +13,7 @@ import ( "github.com/mattermost/mattermost/server/public/shared/request" ) -func (a *App) RestorePostVersion(rctx request.CTX, userID, postID, restoreVersionID string) (*model.Post, *model.AppError) { +func (a *App) RestorePostVersion(rctx request.CTX, userID, postID, restoreVersionID string) (*model.Post, bool, *model.AppError) { toRestorePostVersion, err := a.Srv().Store().Post().GetSingle(rctx, restoreVersionID, true) if err != nil { var statusCode int @@ -25,24 +25,24 @@ func (a *App) RestorePostVersion(rctx request.CTX, userID, postID, restoreVersio statusCode = http.StatusInternalServerError } - return nil, model.NewAppError("RestorePostVersion", "app.post.restore_post_version.get_single.app_error", nil, err.Error(), statusCode) + return nil, false, model.NewAppError("RestorePostVersion", "app.post.restore_post_version.get_single.app_error", nil, err.Error(), statusCode) } // restoreVersionID needs to be an old version of postID // this is only a safeguard and this should never happen in practice. if toRestorePostVersion.OriginalId != postID { - return nil, model.NewAppError("RestorePostVersion", "app.post.restore_post_version.not_an_history_item.app_error", nil, "", http.StatusBadRequest) + return nil, false, model.NewAppError("RestorePostVersion", "app.post.restore_post_version.not_an_history_item.app_error", nil, "", http.StatusBadRequest) } // the user needs to be the author of the post // this is only a safeguard and this should never happen in practice. if toRestorePostVersion.UserId != userID { - return nil, model.NewAppError("RestorePostVersion", "app.post.restore_post_version.not_allowed.app_error", nil, "", http.StatusForbidden) + return nil, false, model.NewAppError("RestorePostVersion", "app.post.restore_post_version.not_allowed.app_error", nil, "", http.StatusForbidden) } // the old version of post needs to be a deleted post if toRestorePostVersion.DeleteAt == 0 { - return nil, model.NewAppError("RestorePostVersion", "app.post.restore_post_version.not_valid_post_history_item.app_error", nil, "", http.StatusBadRequest) + return nil, false, model.NewAppError("RestorePostVersion", "app.post.restore_post_version.not_valid_post_history_item.app_error", nil, "", http.StatusBadRequest) } postPatch := &model.PostPatch{ diff --git a/server/channels/app/post_restore_test.go b/server/channels/app/post_restore_test.go index 627660fb2a8..f7454c05093 100644 --- a/server/channels/app/post_restore_test.go +++ b/server/channels/app/post_restore_test.go @@ -33,9 +33,10 @@ func TestRestorePostVersion(t *testing.T) { require.Equal(t, "new message 2", editHistory[0].Message) // now we'll restore a post version - restoredPost, appErr := th.App.RestorePostVersion(th.Context, th.BasicUser.Id, post.Id, editHistory[0].Id) + restoredPost, isMemberForPreview, appErr := th.App.RestorePostVersion(th.Context, th.BasicUser.Id, post.Id, editHistory[0].Id) require.Nil(t, appErr) require.Equal(t, "new message 2", restoredPost.Message) + require.True(t, isMemberForPreview) // verify from database fetchedPost, err = th.App.Srv().Store().Post().GetSingle(th.Context, post.Id, true) @@ -84,10 +85,11 @@ func TestRestorePostVersion(t *testing.T) { require.Equal(t, 1, len(editHistory[1].FileIds)) // now we'll restore a post version - restoredPost, appErr := th.App.RestorePostVersion(th.Context, th.BasicUser.Id, post.Id, editHistory[1].Id) + restoredPost, isMemberForPreview, appErr := th.App.RestorePostVersion(th.Context, th.BasicUser.Id, post.Id, editHistory[1].Id) require.Nil(t, appErr) require.Equal(t, "original message", restoredPost.Message) require.Equal(t, 1, len(restoredPost.FileIds)) + require.True(t, isMemberForPreview) // verify from database fetchedPost, err = th.App.Srv().Store().Post().GetSingle(th.Context, post.Id, true) @@ -124,7 +126,7 @@ func TestRestorePostVersion(t *testing.T) { // now we'll restore a post version otherPost := th.CreatePost(t, th.BasicChannel) - restoredPost, appErr := th.App.RestorePostVersion(th.Context, th.BasicUser.Id, post.Id, otherPost.Id) + restoredPost, _, appErr := th.App.RestorePostVersion(th.Context, th.BasicUser.Id, post.Id, otherPost.Id) require.NotNil(t, appErr) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) require.Equal(t, "app.post.restore_post_version.not_an_history_item.app_error", appErr.Id) @@ -137,7 +139,7 @@ func TestRestorePostVersion(t *testing.T) { }) t.Run("should return an error if the post does not exist", func(t *testing.T) { - restoredPost, appErr := th.App.RestorePostVersion(th.Context, th.BasicUser.Id, model.NewId(), model.NewId()) + restoredPost, _, appErr := th.App.RestorePostVersion(th.Context, th.BasicUser.Id, model.NewId(), model.NewId()) require.NotNil(t, appErr) require.Equal(t, http.StatusNotFound, appErr.StatusCode) require.Equal(t, "app.post.restore_post_version.get_single.app_error", appErr.Id) @@ -149,7 +151,7 @@ func TestRestorePostVersion(t *testing.T) { // now we'll restore a post version invalidRestorePostIUd := model.NewId() - restoredPost, appErr := th.App.RestorePostVersion(th.Context, th.BasicUser.Id, post.Id, invalidRestorePostIUd) + restoredPost, _, appErr := th.App.RestorePostVersion(th.Context, th.BasicUser.Id, post.Id, invalidRestorePostIUd) require.NotNil(t, appErr) require.Equal(t, http.StatusNotFound, appErr.StatusCode) require.Equal(t, "app.post.restore_post_version.get_single.app_error", appErr.Id) @@ -171,7 +173,7 @@ func TestRestorePostVersion(t *testing.T) { require.Equal(t, "other post original message", otherPostEditHistory[0].Message) // we'll specify post's ID and other post's version ID, his should fail - restoredPost, appErr := th.App.RestorePostVersion(th.Context, th.BasicUser.Id, post.Id, otherPostEditHistory[0].Id) + restoredPost, _, appErr := th.App.RestorePostVersion(th.Context, th.BasicUser.Id, post.Id, otherPostEditHistory[0].Id) require.NotNil(t, appErr) require.Equal(t, "app.post.restore_post_version.not_an_history_item.app_error", appErr.Id) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) diff --git a/server/channels/app/post_test.go b/server/channels/app/post_test.go index 29264ff291e..493ccf179c3 100644 --- a/server/channels/app/post_test.go +++ b/server/channels/app/post_test.go @@ -49,7 +49,7 @@ func TestCreatePostDeduplicate(t *testing.T) { pendingPostId := makePendingPostId(th.BasicUser) - post, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{ + post, _, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "message", @@ -58,7 +58,7 @@ func TestCreatePostDeduplicate(t *testing.T) { require.Nil(t, err) require.Equal(t, "message", post.Message) - duplicatePost, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{ + duplicatePost, _, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "message", @@ -105,7 +105,7 @@ func TestCreatePostDeduplicate(t *testing.T) { pendingPostId := makePendingPostId(th.BasicUser) - post, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{ + post, _, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "message", @@ -115,7 +115,7 @@ func TestCreatePostDeduplicate(t *testing.T) { require.Equal(t, "Post rejected by plugin. rejected", err.Id) require.Nil(t, post) - duplicatePost, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{ + duplicatePost, _, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "message", @@ -171,7 +171,7 @@ func TestCreatePostDeduplicate(t *testing.T) { go func() { defer wg.Done() var appErr *model.AppError - post, appErr = th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{ + post, _, appErr = th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "plugin delayed", @@ -185,7 +185,7 @@ func TestCreatePostDeduplicate(t *testing.T) { time.Sleep(2 * time.Second) // Try creating a duplicate post - duplicatePost, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{ + duplicatePost, _, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "plugin delayed", @@ -214,7 +214,7 @@ func TestCreatePostDeduplicate(t *testing.T) { pendingPostId := makePendingPostId(th.BasicUser) - post, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{ + post, _, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "message", @@ -225,7 +225,7 @@ func TestCreatePostDeduplicate(t *testing.T) { time.Sleep(pendingPostIDsCacheTTL) - duplicatePost, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{ + duplicatePost, _, err := th.App.CreatePostAsUser(th.Context.WithSession(session), &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "message", @@ -254,7 +254,7 @@ func TestCreatePostDeduplicate(t *testing.T) { privateChannel := th.CreatePrivateChannel(t, th.BasicTeam) th.AddUserToChannel(t, th.BasicUser, privateChannel) - post, err := th.App.CreatePostAsUser(th.Context.WithSession(sessionBasicUser), &model.Post{ + post, _, err := th.App.CreatePostAsUser(th.Context.WithSession(sessionBasicUser), &model.Post{ UserId: th.BasicUser.Id, ChannelId: privateChannel.Id, Message: "message", @@ -263,7 +263,7 @@ func TestCreatePostDeduplicate(t *testing.T) { require.Nil(t, err) require.Equal(t, "message", post.Message) - postAsDifferentUser, err := th.App.CreatePostAsUser(th.Context.WithSession(sessionBasicUser2), &model.Post{ + postAsDifferentUser, _, err := th.App.CreatePostAsUser(th.Context.WithSession(sessionBasicUser2), &model.Post{ UserId: th.BasicUser2.Id, ChannelId: th.BasicChannel.Id, Message: "message2", @@ -364,18 +364,19 @@ func TestUpdatePostEditAt(t *testing.T) { post := th.BasicPost.Clone() post.IsPinned = true - saved, err := th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true}) + saved, isMemberForPreviews, err := th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true}) require.Nil(t, err) assert.Equal(t, saved.EditAt, post.EditAt, "shouldn't have updated post.EditAt when pinning post") + assert.True(t, isMemberForPreviews) post = saved.Clone() time.Sleep(time.Millisecond * 100) post.Message = model.NewId() - saved, err = th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true}) + saved, isMemberForPreviews, err = th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true}) require.Nil(t, err) assert.NotEqual(t, saved.EditAt, post.EditAt, "should have updated post.EditAt when updating post message") - + assert.True(t, isMemberForPreviews) time.Sleep(time.Millisecond * 200) } @@ -390,7 +391,7 @@ func TestUpdatePostTimeLimit(t *testing.T) { th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.PostEditTimeLimit = -1 }) - _, err := th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true}) + _, _, err := th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true}) require.Nil(t, err) th.App.UpdateConfig(func(cfg *model.Config) { @@ -398,14 +399,14 @@ func TestUpdatePostTimeLimit(t *testing.T) { }) post.Message = model.NewId() - _, err = th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true}) + _, _, err = th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true}) require.Nil(t, err, "should allow you to edit the post") th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.PostEditTimeLimit = 1 }) post.Message = model.NewId() - _, err = th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true}) + _, _, err = th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true}) require.Nil(t, err, "should allow you to edit an old post because the time check is applied above in the call hierarchy") th.App.UpdateConfig(func(cfg *model.Config) { @@ -422,7 +423,7 @@ func TestUpdatePostInArchivedChannel(t *testing.T) { appErr := th.App.DeleteChannel(th.Context, archivedChannel, "") require.Nil(t, appErr) - _, err := th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true}) + _, _, err := th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true}) require.NotNil(t, err) require.Equal(t, "api.post.update_post.can_not_update_post_in_deleted.error", err.Id) } @@ -452,7 +453,7 @@ func TestPostReplyToPostWhereRootPosterLeftChannel(t *testing.T) { CreateAt: 0, } - _, err = th.App.CreatePostAsUser(th.Context, &replyPost, "", true) + _, _, err = th.App.CreatePostAsUser(th.Context, &replyPost, "", true) require.Nil(t, err) } @@ -473,7 +474,7 @@ func TestPostAttachPostToChildPost(t *testing.T) { CreateAt: 0, } - res1, err := th.App.CreatePostAsUser(th.Context, &replyPost1, "", true) + res1, _, err := th.App.CreatePostAsUser(th.Context, &replyPost1, "", true) require.Nil(t, err) replyPost2 := model.Post{ @@ -485,7 +486,7 @@ func TestPostAttachPostToChildPost(t *testing.T) { CreateAt: 0, } - _, err = th.App.CreatePostAsUser(th.Context, &replyPost2, "", true) + _, _, err = th.App.CreatePostAsUser(th.Context, &replyPost2, "", true) assert.Equalf(t, err.StatusCode, http.StatusBadRequest, "Expected BadRequest error, got %v", err) replyPost3 := model.Post{ @@ -497,7 +498,7 @@ func TestPostAttachPostToChildPost(t *testing.T) { CreateAt: 0, } - _, err = th.App.CreatePostAsUser(th.Context, &replyPost3, "", true) + _, _, err = th.App.CreatePostAsUser(th.Context, &replyPost3, "", true) assert.Nil(t, err) } @@ -559,7 +560,7 @@ func TestUpdatePostPluginHooks(t *testing.T) { }, true, th.App, th.Context) pendingPostId := makePendingPostId(th.BasicUser) - post, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + post, _, err := th.App.CreatePostAsUser(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "message", @@ -568,7 +569,7 @@ func TestUpdatePostPluginHooks(t *testing.T) { require.Nil(t, err) post.Message = "new message" - updatedPost, err := th.App.UpdatePost(th.Context, post, nil) + updatedPost, _, err := th.App.UpdatePost(th.Context, post, nil) require.Nil(t, updatedPost) require.NotNil(t, err) require.Equal(t, "Post rejected by plugin. rejected", err.Id) @@ -626,7 +627,7 @@ func TestUpdatePostPluginHooks(t *testing.T) { }, true, th.App, th.Context) pendingPostId := makePendingPostId(th.BasicUser) - post, err := th.App.CreatePostAsUser(th.Context, &model.Post{ + post, _, err := th.App.CreatePostAsUser(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: "message", @@ -635,7 +636,8 @@ func TestUpdatePostPluginHooks(t *testing.T) { require.Nil(t, err) post.Message = "new message" - updatedPost, err := th.App.UpdatePost(th.Context, post, nil) + updatedPost, isMemberForPreviews, err := th.App.UpdatePost(th.Context, post, nil) + require.True(t, isMemberForPreviews) require.Nil(t, err) require.NotNil(t, updatedPost) require.Equal(t, "2 new message 1", updatedPost.Message) @@ -683,7 +685,7 @@ func TestPostChannelMentions(t *testing.T) { CreateAt: 0, } - post, err = th.App.CreatePostAsUser(th.Context, post, "", true) + post, _, err = th.App.CreatePostAsUser(th.Context, post, "", true) require.Nil(t, err) assert.Equal(t, map[string]any{ "mention-test": map[string]any{ @@ -693,7 +695,8 @@ func TestPostChannelMentions(t *testing.T) { }, post.GetProp(model.PostPropsChannelMentions)) post.Message = fmt.Sprintf("goodbye, ~%v!", channelToMention2.Name) - result, err := th.App.UpdatePost(th.Context, post, nil) + result, isMemberForPreviews, err := th.App.UpdatePost(th.Context, post, nil) + require.True(t, isMemberForPreviews) require.Nil(t, err) assert.Equal(t, map[string]any{ "mention-test2": map[string]any{ @@ -703,7 +706,8 @@ func TestPostChannelMentions(t *testing.T) { }, result.GetProp(model.PostPropsChannelMentions)) result.Message = "no more mentions!" - result, err = th.App.UpdatePost(th.Context, result, nil) + result, isMemberForPreviews, err = th.App.UpdatePost(th.Context, result, nil) + require.True(t, isMemberForPreviews) require.Nil(t, err) assert.Nil(t, result.GetProp(model.PostPropsChannelMentions)) } @@ -860,7 +864,7 @@ func TestDeletePostWithFileAttachments(t *testing.T) { FileIds: []string{info1.Id}, } - post, err = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + post, _, err = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) assert.Nil(t, err) // Delete the post. @@ -914,7 +918,7 @@ func TestDeletePostWithRestrictedDM(t *testing.T) { ChannelId: dmChannel.Id, Message: "test post", } - post, err = th.App.CreatePost(th.Context, post, dmChannel, model.CreatePostFlags{}) + post, _, err = th.App.CreatePost(th.Context, post, dmChannel, model.CreatePostFlags{}) require.Nil(t, err) // Try to delete the post @@ -969,7 +973,7 @@ func TestCreatePost(t *testing.T) { UserId: th.BasicUser.Id, } - rpost, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + rpost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) assert.Equal(t, "![image]("+proxiedImageURL+")", rpost.Message) }) @@ -986,7 +990,7 @@ func TestCreatePost(t *testing.T) { Message: "This post does not have mentions", UserId: th.BasicUser.Id, } - rpost, err := th.App.CreatePost(th.Context, postWithNoMention, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + rpost, _, err := th.App.CreatePost(th.Context, postWithNoMention, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) assert.Equal(t, rpost.GetProps(), model.StringInterface{}) @@ -995,7 +999,7 @@ func TestCreatePost(t *testing.T) { Message: "This post has @here mention @all", UserId: th.BasicUser.Id, } - rpost, err = th.App.CreatePost(th.Context, postWithMention, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + rpost, _, err = th.App.CreatePost(th.Context, postWithMention, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) assert.Equal(t, rpost.GetProps(), model.StringInterface{}) }) @@ -1009,7 +1013,7 @@ func TestCreatePost(t *testing.T) { Message: "This post does not have mentions", UserId: th.BasicUser.Id, } - rpost, err := th.App.CreatePost(th.Context, postWithNoMention, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + rpost, _, err := th.App.CreatePost(th.Context, postWithNoMention, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) assert.Equal(t, rpost.GetProps(), model.StringInterface{}) @@ -1018,7 +1022,7 @@ func TestCreatePost(t *testing.T) { Message: "This post has @here mention @all", UserId: th.BasicUser.Id, } - rpost, err = th.App.CreatePost(th.Context, postWithMention, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + rpost, _, err = th.App.CreatePost(th.Context, postWithMention, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) assert.Equal(t, rpost.GetProp(model.PostPropsMentionHighlightDisabled), true) @@ -1045,7 +1049,7 @@ func TestCreatePost(t *testing.T) { th.Context.Session().UserId = th.BasicUser.Id - referencedPost, err := th.App.CreatePost(th.Context, referencedPost, th.BasicChannel, model.CreatePostFlags{}) + referencedPost, _, err := th.App.CreatePost(th.Context, referencedPost, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, err) permalink := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id) @@ -1057,7 +1061,7 @@ func TestCreatePost(t *testing.T) { UserId: th.BasicUser.Id, } - previewPost, err = th.App.CreatePost(th.Context, previewPost, channelForPreview, model.CreatePostFlags{}) + previewPost, _, err = th.App.CreatePost(th.Context, previewPost, channelForPreview, model.CreatePostFlags{}) require.Nil(t, err) assert.Equal(t, previewPost.GetProps(), model.StringInterface{"previewed_post": referencedPost.Id}) @@ -1074,7 +1078,7 @@ func TestCreatePost(t *testing.T) { Message: "hello world", UserId: th.BasicUser.Id, } - referencedPost, err := th.App.CreatePost(th.Context, referencedPost, th.BasicChannel, model.CreatePostFlags{}) + referencedPost, _, err := th.App.CreatePost(th.Context, referencedPost, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, err) th.App.UpdateConfig(func(cfg *model.Config) { @@ -1090,7 +1094,7 @@ func TestCreatePost(t *testing.T) { UserId: th.BasicUser.Id, } - previewPost, err = th.App.CreatePost(th.Context, previewPost, channelForPreview, model.CreatePostFlags{}) + previewPost, _, err = th.App.CreatePost(th.Context, previewPost, channelForPreview, model.CreatePostFlags{}) require.Nil(t, err) sqlStore := th.GetSqlStore() @@ -1146,7 +1150,7 @@ func TestCreatePost(t *testing.T) { Message: "hello world", UserId: testCase.Author, } - referencedPost, err = th.App.CreatePost(th.Context, referencedPost, testCase.Channel, model.CreatePostFlags{}) + referencedPost, _, err = th.App.CreatePost(th.Context, referencedPost, testCase.Channel, model.CreatePostFlags{}) require.Nil(t, err) permalink := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id) @@ -1156,7 +1160,7 @@ func TestCreatePost(t *testing.T) { UserId: th.BasicUser.Id, } - previewPost, err = th.App.CreatePost(th.Context, previewPost, th.BasicChannel, model.CreatePostFlags{}) + previewPost, _, err = th.App.CreatePost(th.Context, previewPost, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, err) require.Len(t, previewPost.Metadata.Embeds, testCase.Length) @@ -1198,7 +1202,7 @@ func TestCreatePost(t *testing.T) { Message: "hello world", UserId: user1.Id, } - createdPost, appErr := th.App.CreatePost(th.Context, newPost, dm, model.CreatePostFlags{}) + createdPost, _, appErr := th.App.CreatePost(th.Context, newPost, dm, model.CreatePostFlags{}) require.NotNil(t, appErr) require.Nil(t, createdPost) }) @@ -1238,7 +1242,7 @@ func TestCreatePost(t *testing.T) { Message: "hello world", UserId: user1.Id, } - createdPost, appErr := th.App.CreatePost(th.Context, newPost, gm, model.CreatePostFlags{}) + createdPost, _, appErr := th.App.CreatePost(th.Context, newPost, gm, model.CreatePostFlags{}) require.NotNil(t, appErr) require.Nil(t, createdPost) }) @@ -1260,7 +1264,7 @@ func TestCreatePost(t *testing.T) { Message: "hello world", UserId: th.BasicUser.Id, } - referencedPost, err := th.App.CreatePost(th.Context, referencedPost, th.BasicChannel, model.CreatePostFlags{}) + referencedPost, _, err := th.App.CreatePost(th.Context, referencedPost, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, err) th.App.UpdateConfig(func(cfg *model.Config) { @@ -1276,7 +1280,7 @@ func TestCreatePost(t *testing.T) { UserId: th.BasicUser.Id, } - previewPost, err = th.App.CreatePost(th.Context, previewPost, channelForPreview, model.CreatePostFlags{}) + previewPost, _, err = th.App.CreatePost(th.Context, previewPost, channelForPreview, model.CreatePostFlags{}) require.Nil(t, err) n := 1000 @@ -1286,7 +1290,7 @@ func TestCreatePost(t *testing.T) { go func() { defer wg.Done() post := previewPost.Clone() - _, appErr := th.App.UpdatePost(th.Context, post, nil) + _, _, appErr := th.App.UpdatePost(th.Context, post, nil) require.Nil(t, appErr) }() } @@ -1306,7 +1310,7 @@ func TestCreatePost(t *testing.T) { UserId: th.BasicUser.Id, } postToCreate.AddProp(model.PostPropsForceNotification, model.NewId()) - createdPost, err := th.App.CreatePost(th.Context, postToCreate, th.BasicChannel, model.CreatePostFlags{}) + createdPost, _, err := th.App.CreatePost(th.Context, postToCreate, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, err) require.Empty(t, createdPost.GetProp(model.PostPropsForceNotification)) }) @@ -1322,7 +1326,7 @@ func TestCreatePost(t *testing.T) { Message: "hello world", UserId: th.BasicUser.Id, } - createdPost, err := th.App.CreatePost(th.Context, postToCreate, th.BasicChannel, model.CreatePostFlags{ForceNotification: true}) + createdPost, _, err := th.App.CreatePost(th.Context, postToCreate, th.BasicChannel, model.CreatePostFlags{ForceNotification: true}) require.Nil(t, err) require.NotEmpty(t, createdPost.GetProp(model.PostPropsForceNotification)) }) @@ -1343,7 +1347,7 @@ func TestCreatePost(t *testing.T) { FileIds: []string{model.NewId()}, } - createdPost, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) + createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) require.Empty(t, createdPost.FileIds) }) @@ -1374,7 +1378,7 @@ func TestPatchPost(t *testing.T) { UserId: th.BasicUser.Id, } - rpost, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + rpost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) assert.NotEqual(t, "![image]("+proxiedImageURL+")", rpost.Message) @@ -1382,7 +1386,7 @@ func TestPatchPost(t *testing.T) { Message: model.NewPointer("![image](" + imageURL + ")"), } - rpost, err = th.App.PatchPost(th.Context, rpost.Id, patch, nil) + rpost, _, err = th.App.PatchPost(th.Context, rpost.Id, patch, nil) require.Nil(t, err) assert.Equal(t, "![image]("+proxiedImageURL+")", rpost.Message) }) @@ -1399,19 +1403,19 @@ func TestPatchPost(t *testing.T) { UserId: th.BasicUser.Id, } - rpost, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + rpost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) t.Run("Does not set prop when user has USE_CHANNEL_MENTIONS", func(t *testing.T) { patchWithNoMention := &model.PostPatch{Message: model.NewPointer("This patch has no channel mention")} - rpost, err = th.App.PatchPost(th.Context, rpost.Id, patchWithNoMention, nil) + rpost, _, err = th.App.PatchPost(th.Context, rpost.Id, patchWithNoMention, nil) require.Nil(t, err) assert.Equal(t, rpost.GetProps(), model.StringInterface{}) patchWithMention := &model.PostPatch{Message: model.NewPointer("This patch has a mention now @here")} - rpost, err = th.App.PatchPost(th.Context, rpost.Id, patchWithMention, nil) + rpost, _, err = th.App.PatchPost(th.Context, rpost.Id, patchWithMention, nil) require.Nil(t, err) assert.Equal(t, rpost.GetProps(), model.StringInterface{}) }) @@ -1421,13 +1425,13 @@ func TestPatchPost(t *testing.T) { th.RemovePermissionFromRole(t, model.PermissionUseChannelMentions.Id, model.ChannelAdminRoleId) patchWithNoMention := &model.PostPatch{Message: model.NewPointer("This patch still does not have a mention")} - rpost, err = th.App.PatchPost(th.Context, rpost.Id, patchWithNoMention, nil) + rpost, _, err = th.App.PatchPost(th.Context, rpost.Id, patchWithNoMention, nil) require.Nil(t, err) assert.Equal(t, rpost.GetProps(), model.StringInterface{}) patchWithMention := &model.PostPatch{Message: model.NewPointer("This patch has a mention now @here")} - rpost, err = th.App.PatchPost(th.Context, rpost.Id, patchWithMention, nil) + rpost, _, err = th.App.PatchPost(th.Context, rpost.Id, patchWithMention, nil) require.Nil(t, err) assert.Equal(t, rpost.GetProp(model.PostPropsMentionHighlightDisabled), true) @@ -1473,14 +1477,14 @@ func TestPatchPost(t *testing.T) { ChannelId: dmChannel.Id, Message: "test post", } - post, err = th.App.CreatePost(th.Context, post, dmChannel, model.CreatePostFlags{}) + post, _, err = th.App.CreatePost(th.Context, post, dmChannel, model.CreatePostFlags{}) require.Nil(t, err) // Try to patch the post patch := &model.PostPatch{ Message: model.NewPointer("updated message"), } - _, appErr := th.App.PatchPost(th.Context, post.Id, patch, model.DefaultUpdatePostOptions()) + _, _, appErr := th.App.PatchPost(th.Context, post.Id, patch, model.DefaultUpdatePostOptions()) require.NotNil(t, appErr) require.Equal(t, "api.post.patch_post.can_not_update_post_in_restricted_dm.error", appErr.Id) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) @@ -1508,7 +1512,7 @@ func TestCreatePostAsUser(t *testing.T) { require.NoError(t, err) time.Sleep(1 * time.Millisecond) - _, appErr := th.App.CreatePostAsUser(th.Context, post, "", true) + _, _, appErr := th.App.CreatePostAsUser(th.Context, post, "", true) require.Nil(t, appErr) channelMemberAfter, err := th.App.Srv().Store().Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id) @@ -1532,7 +1536,7 @@ func TestCreatePostAsUser(t *testing.T) { require.NoError(t, err) time.Sleep(1 * time.Millisecond) - _, appErr := th.App.CreatePostAsUser(th.Context, post, "", true) + _, _, appErr := th.App.CreatePostAsUser(th.Context, post, "", true) require.Nil(t, appErr) channelMemberAfter, err := th.App.Srv().Store().Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id) @@ -1563,7 +1567,7 @@ func TestCreatePostAsUser(t *testing.T) { require.NoError(t, err) time.Sleep(1 * time.Millisecond) - _, appErr = th.App.CreatePostAsUser(th.Context, post, "", true) + _, _, appErr = th.App.CreatePostAsUser(th.Context, post, "", true) require.Nil(t, appErr) channelMemberAfter, err := th.App.Srv().Store().Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id) @@ -1589,7 +1593,7 @@ func TestCreatePostAsUser(t *testing.T) { UserId: bot.UserId, } - _, appErr = th.App.CreatePostAsUser(th.Context, post, "", true) + _, _, appErr = th.App.CreatePostAsUser(th.Context, post, "", true) require.Nil(t, appErr) require.NoError(t, th.TestLogger.Flush()) @@ -1610,7 +1614,7 @@ func TestCreatePostAsUser(t *testing.T) { Message: "test", UserId: th.BasicUser2.Id, } - rootPost, appErr := th.App.CreatePostAsUser(th.Context, post, "", true) + rootPost, _, appErr := th.App.CreatePostAsUser(th.Context, post, "", true) require.Nil(t, appErr) channelMemberBefore, err := th.App.Srv().Store().Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id) @@ -1623,7 +1627,7 @@ func TestCreatePostAsUser(t *testing.T) { UserId: th.BasicUser.Id, RootId: rootPost.Id, } - _, appErr = th.App.CreatePostAsUser(th.Context, replyPost, "", true) + _, _, appErr = th.App.CreatePostAsUser(th.Context, replyPost, "", true) require.Nil(t, appErr) channelMemberAfter, err := th.App.Srv().Store().Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id) @@ -1646,7 +1650,7 @@ func TestCreatePostAsUser(t *testing.T) { Message: "test", UserId: th.BasicUser2.Id, } - rootPost, appErr := th.App.CreatePostAsUser(th.Context, post, "", true) + rootPost, _, appErr := th.App.CreatePostAsUser(th.Context, post, "", true) require.Nil(t, appErr) channelMemberBefore, err := th.App.Srv().Store().Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id) @@ -1659,7 +1663,7 @@ func TestCreatePostAsUser(t *testing.T) { UserId: th.BasicUser.Id, RootId: rootPost.Id, } - _, appErr = th.App.CreatePostAsUser(th.Context, replyPost, "", true) + _, _, appErr = th.App.CreatePostAsUser(th.Context, replyPost, "", true) require.Nil(t, appErr) channelMemberAfter, err := th.App.Srv().Store().Channel().GetMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id) @@ -1705,7 +1709,7 @@ func TestCreatePostAsUser(t *testing.T) { Message: "test post", } - _, appErr := th.App.CreatePostAsUser(th.Context, post, "", true) + _, _, appErr := th.App.CreatePostAsUser(th.Context, post, "", true) require.NotNil(t, appErr) require.Equal(t, "api.post.create_post.can_not_post_in_restricted_dm.error", appErr.Id) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) @@ -1726,7 +1730,7 @@ func TestPatchPostInArchivedChannel(t *testing.T) { appErr := th.App.DeleteChannel(th.Context, archivedChannel, "") require.Nil(t, appErr) - _, err := th.App.PatchPost(th.Context, post.Id, &model.PostPatch{IsPinned: model.NewPointer(true)}, nil) + _, _, err := th.App.PatchPost(th.Context, post.Id, &model.PostPatch{IsPinned: model.NewPointer(true)}, nil) require.NotNil(t, err) require.Equal(t, "api.post.patch_post.can_not_update_post_in_deleted.error", err.Id) } @@ -1751,7 +1755,7 @@ func TestUpdateEphemeralPost(t *testing.T) { th.Context.Session().UserId = th.BasicUser.Id - referencedPost, err := th.App.CreatePost(th.Context, referencedPost, th.BasicChannel, model.CreatePostFlags{}) + referencedPost, _, err := th.App.CreatePost(th.Context, referencedPost, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, err) permalink := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id) @@ -1762,7 +1766,7 @@ func TestUpdateEphemeralPost(t *testing.T) { UserId: th.BasicUser.Id, } - testPost = th.App.UpdateEphemeralPost(th.Context, th.BasicUser.Id, testPost) + testPost, _ = th.App.UpdateEphemeralPost(th.Context, th.BasicUser.Id, testPost) require.NotNil(t, testPost.Metadata) require.Len(t, testPost.Metadata.Embeds, 1) require.Equal(t, model.PostEmbedPermalink, testPost.Metadata.Embeds[0].Type) @@ -1788,7 +1792,7 @@ func TestUpdateEphemeralPost(t *testing.T) { th.Context.Session().UserId = th.BasicUser.Id - referencedPost, err := th.App.CreatePost(th.Context, referencedPost, th.BasicChannel, model.CreatePostFlags{}) + referencedPost, _, err := th.App.CreatePost(th.Context, referencedPost, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, err) permalink := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id) @@ -1799,7 +1803,7 @@ func TestUpdateEphemeralPost(t *testing.T) { UserId: th.BasicUser2.Id, } - testPost = th.App.UpdateEphemeralPost(th.Context, th.BasicUser2.Id, testPost) + testPost, _ = th.App.UpdateEphemeralPost(th.Context, th.BasicUser2.Id, testPost) require.Nil(t, testPost.Metadata.Embeds) }) } @@ -1829,14 +1833,15 @@ func TestUpdatePost(t *testing.T) { UserId: th.BasicUser.Id, } - rpost, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + rpost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) assert.NotEqual(t, "![image]("+proxiedImageURL+")", rpost.Message) post.Id = rpost.Id post.Message = "![image](" + imageURL + ")" - rpost, err = th.App.UpdatePost(th.Context, post, nil) + rpost, isMemberForPreviews, err := th.App.UpdatePost(th.Context, post, nil) + require.True(t, isMemberForPreviews) require.Nil(t, err) assert.Equal(t, "![image]("+proxiedImageURL+")", rpost.Message) }) @@ -1859,7 +1864,7 @@ func TestUpdatePost(t *testing.T) { th.Context.Session().UserId = th.BasicUser.Id - referencedPost, err := th.App.CreatePost(th.Context, referencedPost, th.BasicChannel, model.CreatePostFlags{}) + referencedPost, _, err := th.App.CreatePost(th.Context, referencedPost, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, err) permalink := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id) @@ -1871,12 +1876,13 @@ func TestUpdatePost(t *testing.T) { UserId: th.BasicUser.Id, } - testPost, err = th.App.CreatePost(th.Context, testPost, channelForTestPost, model.CreatePostFlags{}) + testPost, _, err = th.App.CreatePost(th.Context, testPost, channelForTestPost, model.CreatePostFlags{}) require.Nil(t, err) assert.Equal(t, model.StringInterface{}, testPost.GetProps()) testPost.Message = permalink - testPost, err = th.App.UpdatePost(th.Context, testPost, nil) + testPost, isMemberForPreviews, err := th.App.UpdatePost(th.Context, testPost, nil) + require.True(t, isMemberForPreviews) require.Nil(t, err) assert.Equal(t, model.StringInterface{model.PostPropsPreviewedPost: referencedPost.Id}, testPost.GetProps()) }) @@ -1925,19 +1931,20 @@ func TestUpdatePost(t *testing.T) { Message: "hello world", UserId: testCase.Author, } - _, err = th.App.CreatePost(th.Context, referencedPost, testCase.Channel, model.CreatePostFlags{}) + _, _, err = th.App.CreatePost(th.Context, referencedPost, testCase.Channel, model.CreatePostFlags{}) require.Nil(t, err) previewPost := &model.Post{ ChannelId: th.BasicChannel.Id, UserId: th.BasicUser.Id, } - previewPost, err = th.App.CreatePost(th.Context, previewPost, th.BasicChannel, model.CreatePostFlags{}) + previewPost, _, err = th.App.CreatePost(th.Context, previewPost, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, err) permalink := fmt.Sprintf("%s/%s/pl/%s", *th.App.Config().ServiceSettings.SiteURL, th.BasicTeam.Name, referencedPost.Id) previewPost.Message = permalink - previewPost, err = th.App.UpdatePost(th.Context, previewPost, nil) + previewPost, isMemberForPreviews, err := th.App.UpdatePost(th.Context, previewPost, nil) + require.True(t, isMemberForPreviews) require.Nil(t, err) require.Len(t, previewPost.Metadata.Embeds, testCase.Length) @@ -1982,12 +1989,12 @@ func TestUpdatePost(t *testing.T) { ChannelId: dmChannel.Id, Message: "test post", } - post, err = th.App.CreatePost(th.Context, post, dmChannel, model.CreatePostFlags{}) + post, _, err = th.App.CreatePost(th.Context, post, dmChannel, model.CreatePostFlags{}) require.Nil(t, err) // Try to update the post post.Message = "updated message" - _, appErr := th.App.UpdatePost(th.Context, post, model.DefaultUpdatePostOptions()) + _, _, appErr := th.App.UpdatePost(th.Context, post, model.DefaultUpdatePostOptions()) require.NotNil(t, appErr) require.Equal(t, "api.post.update_post.can_not_update_post_in_restricted_dm.error", appErr.Id) require.Equal(t, http.StatusBadRequest, appErr.StatusCode) @@ -2009,7 +2016,7 @@ func TestSearchPostsForUser(t *testing.T) { posts := make([]*model.Post, 7) for i := 0; i < cap(posts); i++ { - post, err := th.App.CreatePost(th.Context, &model.Post{ + post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: th.BasicUser.Id, ChannelId: th.BasicChannel.Id, Message: searchTerm, @@ -2042,7 +2049,7 @@ func TestSearchPostsForUser(t *testing.T) { page := 0 - results, err := th.App.SearchPostsForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) + results, allPostHaveMembership, err := th.App.SearchPostsForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) assert.Nil(t, err) assert.Equal(t, []string{ @@ -2054,6 +2061,7 @@ func TestSearchPostsForUser(t *testing.T) { posts[1].Id, posts[0].Id, }, results.Order) + assert.True(t, allPostHaveMembership) }) t.Run("should not return later pages of posts from database", func(t *testing.T) { @@ -2062,10 +2070,11 @@ func TestSearchPostsForUser(t *testing.T) { page := 1 - results, err := th.App.SearchPostsForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) + results, allPostHaveMembership, err := th.App.SearchPostsForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) assert.Nil(t, err) assert.Equal(t, []string{}, results.Order) + assert.True(t, allPostHaveMembership) }) t.Run("should return first page of posts from ElasticSearch", func(t *testing.T) { @@ -2091,10 +2100,11 @@ func TestSearchPostsForUser(t *testing.T) { th.App.Srv().Platform().SearchEngine.ElasticsearchEngine = nil }() - results, err := th.App.SearchPostsForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) + results, allPostHaveMembership, err := th.App.SearchPostsForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) assert.Nil(t, err) assert.Equal(t, resultsPage, results.Order) + assert.True(t, allPostHaveMembership) es.AssertExpectations(t) }) @@ -2118,10 +2128,11 @@ func TestSearchPostsForUser(t *testing.T) { th.App.Srv().Platform().SearchEngine.ElasticsearchEngine = nil }() - results, err := th.App.SearchPostsForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) + results, allPostHaveMembership, err := th.App.SearchPostsForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) assert.Nil(t, err) assert.Equal(t, resultsPage, results.Order) + assert.True(t, allPostHaveMembership) es.AssertExpectations(t) }) @@ -2142,7 +2153,7 @@ func TestSearchPostsForUser(t *testing.T) { th.App.Srv().Platform().SearchEngine.ElasticsearchEngine = nil }() - results, err := th.App.SearchPostsForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) + results, allPostHaveMembership, err := th.App.SearchPostsForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) assert.Nil(t, err) assert.Equal(t, []string{ @@ -2154,6 +2165,7 @@ func TestSearchPostsForUser(t *testing.T) { posts[1].Id, posts[0].Id, }, results.Order) + assert.True(t, allPostHaveMembership) es.AssertExpectations(t) }) @@ -2174,10 +2186,11 @@ func TestSearchPostsForUser(t *testing.T) { th.App.Srv().Platform().SearchEngine.ElasticsearchEngine = nil }() - results, err := th.App.SearchPostsForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) + results, allPostHaveMembership, err := th.App.SearchPostsForUser(th.Context, searchTerm, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) assert.Nil(t, err) assert.Equal(t, []string{}, results.Order) + assert.True(t, allPostHaveMembership) es.AssertExpectations(t) }) @@ -2189,12 +2202,12 @@ func TestSearchPostsForUser(t *testing.T) { searchQueryWithPrefix := fmt.Sprintf("in:~%s %s", th.BasicChannel.Name, searchTerm) - resultsWithPrefix, err := th.App.SearchPostsForUser(th.Context, searchQueryWithPrefix, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) + resultsWithPrefix, _, err := th.App.SearchPostsForUser(th.Context, searchQueryWithPrefix, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) assert.Nil(t, err) assert.Greater(t, len(resultsWithPrefix.PostList.Posts), 0, "searching using a tilde in front of a channel should return results") searchQueryWithoutPrefix := fmt.Sprintf("in:%s %s", th.BasicChannel.Name, searchTerm) - resultsWithoutPrefix, err := th.App.SearchPostsForUser(th.Context, searchQueryWithoutPrefix, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) + resultsWithoutPrefix, _, err := th.App.SearchPostsForUser(th.Context, searchQueryWithoutPrefix, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) assert.Nil(t, err) assert.Equal(t, len(resultsWithPrefix.Posts), len(resultsWithoutPrefix.Posts), "searching using a tilde in front of a channel should return the same number of results") for k, v := range resultsWithPrefix.Posts { @@ -2210,12 +2223,12 @@ func TestSearchPostsForUser(t *testing.T) { searchQueryWithPrefix := fmt.Sprintf("from:@%s %s", th.BasicUser.Username, searchTerm) - resultsWithPrefix, err := th.App.SearchPostsForUser(th.Context, searchQueryWithPrefix, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) + resultsWithPrefix, _, err := th.App.SearchPostsForUser(th.Context, searchQueryWithPrefix, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) assert.Nil(t, err) assert.Greater(t, len(resultsWithPrefix.PostList.Posts), 0, "searching using a 'at' symbol in front of a channel should return results") searchQueryWithoutPrefix := fmt.Sprintf("from:@%s %s", th.BasicUser.Username, searchTerm) - resultsWithoutPrefix, err := th.App.SearchPostsForUser(th.Context, searchQueryWithoutPrefix, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) + resultsWithoutPrefix, _, err := th.App.SearchPostsForUser(th.Context, searchQueryWithoutPrefix, th.BasicUser.Id, th.BasicTeam.Id, false, false, 0, page, perPage) assert.Nil(t, err) assert.Equal(t, len(resultsWithPrefix.Posts), len(resultsWithoutPrefix.Posts), "searching using an 'at' symbol in front of a channel should return the same number of results") for k, v := range resultsWithPrefix.Posts { @@ -2236,19 +2249,19 @@ func TestCountMentionsFromPost(t *testing.T) { channel := th.CreateChannel(t, th.BasicTeam) th.AddUserToChannel(t, user2, channel) - post1, err := th.App.CreatePost(th.Context, &model.Post{ + post1, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test2", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test3", @@ -2273,19 +2286,19 @@ func TestCountMentionsFromPost(t *testing.T) { user2.NotifyProps[model.MentionKeysNotifyProp] = "apple" - post1, err := th.App.CreatePost(th.Context, &model.Post{ + post1, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: fmt.Sprintf("@%s", user2.Username), }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test2", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "apple", @@ -2312,19 +2325,19 @@ func TestCountMentionsFromPost(t *testing.T) { user2.NotifyProps[model.ChannelMentionsNotifyProp] = "true" - post1, err := th.App.CreatePost(th.Context, &model.Post{ + post1, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "@channel", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "@all", @@ -2351,19 +2364,19 @@ func TestCountMentionsFromPost(t *testing.T) { user2.NotifyProps[model.ChannelMentionsNotifyProp] = "false" - post1, err := th.App.CreatePost(th.Context, &model.Post{ + post1, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "@channel", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "@all", @@ -2393,19 +2406,19 @@ func TestCountMentionsFromPost(t *testing.T) { }, channel.Id, user2.Id) require.Nil(t, err) - post1, err := th.App.CreatePost(th.Context, &model.Post{ + post1, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "@channel", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "@all", @@ -2430,33 +2443,33 @@ func TestCountMentionsFromPost(t *testing.T) { user2.NotifyProps[model.CommentsNotifyProp] = model.CommentsNotifyRoot - post1, err := th.App.CreatePost(th.Context, &model.Post{ + post1, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user2.Id, ChannelId: channel.Id, Message: "test", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, RootId: post1.Id, Message: "test2", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - post3, err := th.App.CreatePost(th.Context, &model.Post{ + post3, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test3", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user2.Id, ChannelId: channel.Id, RootId: post3.Id, Message: "test4", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, RootId: post3.Id, @@ -2484,33 +2497,33 @@ func TestCountMentionsFromPost(t *testing.T) { user2.NotifyProps[model.CommentsNotifyProp] = model.CommentsNotifyAny - post1, err := th.App.CreatePost(th.Context, &model.Post{ + post1, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user2.Id, ChannelId: channel.Id, Message: "test", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, RootId: post1.Id, Message: "test2", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - post3, err := th.App.CreatePost(th.Context, &model.Post{ + post3, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test3", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user2.Id, ChannelId: channel.Id, RootId: post3.Id, Message: "test4", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, RootId: post3.Id, @@ -2536,7 +2549,7 @@ func TestCountMentionsFromPost(t *testing.T) { channel := th.CreateChannel(t, th.BasicTeam) th.AddUserToChannel(t, user2, channel) - post1, err := th.App.CreatePost(th.Context, &model.Post{ + post1, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test", @@ -2546,7 +2559,7 @@ func TestCountMentionsFromPost(t *testing.T) { }, }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test2", @@ -2556,7 +2569,7 @@ func TestCountMentionsFromPost(t *testing.T) { }, }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test3", @@ -2585,14 +2598,14 @@ func TestCountMentionsFromPost(t *testing.T) { channel, err := th.App.createDirectChannel(th.Context, user1.Id, user2.Id) require.Nil(t, err) - post1, err := th.App.CreatePost(th.Context, &model.Post{ + post1, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test2", @@ -2621,21 +2634,21 @@ func TestCountMentionsFromPost(t *testing.T) { channel, err := th.App.createGroupChannel(th.Context, []string{user1.Id, user2.Id, user3.Id}, user1.Id) require.Nil(t, err) - post1, err := th.App.CreatePost(th.Context, &model.Post{ + post1, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test2", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user3.Id, ChannelId: channel.Id, Message: "test3", @@ -2663,19 +2676,19 @@ func TestCountMentionsFromPost(t *testing.T) { channel := th.CreateChannel(t, th.BasicTeam) th.AddUserToChannel(t, user2, channel) - _, err := th.App.CreatePost(th.Context, &model.Post{ + _, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: fmt.Sprintf("@%s", user2.Username), }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - post2, err := th.App.CreatePost(th.Context, &model.Post{ + post2, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test2", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: fmt.Sprintf("@%s", user2.Username), @@ -2700,13 +2713,13 @@ func TestCountMentionsFromPost(t *testing.T) { channel := th.CreateChannel(t, th.BasicTeam) th.AddUserToChannel(t, user2, channel) - post1, err := th.App.CreatePost(th.Context, &model.Post{ + post1, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: fmt.Sprintf("@%s", user2.Username), }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user2.Id, ChannelId: channel.Id, Message: fmt.Sprintf("@%s", user2.Username), @@ -2733,26 +2746,26 @@ func TestCountMentionsFromPost(t *testing.T) { user2.NotifyProps[model.CommentsNotifyProp] = model.CommentsNotifyAny - post1, err := th.App.CreatePost(th.Context, &model.Post{ + post1, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test1", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user2.Id, ChannelId: channel.Id, RootId: post1.Id, Message: "test2", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - post3, err := th.App.CreatePost(th.Context, &model.Post{ + post3, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test3", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, RootId: post1.Id, @@ -2787,13 +2800,13 @@ func TestCountMentionsFromPost(t *testing.T) { user2.NotifyProps[model.CommentsNotifyProp] = model.CommentsNotifyAny - post1, err := th.App.CreatePost(th.Context, &model.Post{ + post1, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test1", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user2.Id, ChannelId: channel.Id, RootId: post1.Id, @@ -2803,13 +2816,13 @@ func TestCountMentionsFromPost(t *testing.T) { time.Sleep(time.Millisecond * 2) - post3, err := th.App.CreatePost(th.Context, &model.Post{ + post3, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test3", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, RootId: post1.Id, @@ -2843,19 +2856,19 @@ func TestCountMentionsFromPost(t *testing.T) { channel := th.CreateChannel(t, th.BasicTeam) th.AddUserToChannel(t, user2, channel) - post1, err := th.App.CreatePost(th.Context, &model.Post{ + post1, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test1", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user2.Id, ChannelId: channel.Id, Message: fmt.Sprintf("@%s", user2.Username), }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user2.Id, ChannelId: channel.Id, Message: fmt.Sprintf("@%s", user2.Username), @@ -2885,7 +2898,7 @@ func TestCountMentionsFromPost(t *testing.T) { numPosts := 215 - post1, err := th.App.CreatePost(th.Context, &model.Post{ + post1, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: fmt.Sprintf("@%s", user2.Username), @@ -2893,7 +2906,7 @@ func TestCountMentionsFromPost(t *testing.T) { require.Nil(t, err) for i := 0; i < numPosts-1; i++ { - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: fmt.Sprintf("@%s", user2.Username), @@ -2925,7 +2938,7 @@ func TestCountMentionsFromPost(t *testing.T) { user2.NotifyProps[model.MentionKeysNotifyProp] = "apple" - post1, err := th.App.CreatePost(th.Context, &model.Post{ + post1, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: fmt.Sprintf("@%s", user2.Username), @@ -2937,14 +2950,14 @@ func TestCountMentionsFromPost(t *testing.T) { }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: fmt.Sprintf("@%s", user2.Username), }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "apple", @@ -2977,7 +2990,7 @@ func TestFillInPostProps(t *testing.T) { channel := th.CreateChannel(t, th.BasicTeam) - post1, err := th.App.CreatePost(th.Context, &model.Post{ + post1, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "test123123 @group1 @group2 blah blah blah", @@ -3009,7 +3022,7 @@ func TestFillInPostProps(t *testing.T) { channel := th.CreateChannel(t, th.BasicTeam) th.AddUserToChannel(t, guest, channel) - post1, err := th.App.CreatePost(th.Context, &model.Post{ + post1, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: guest.Id, ChannelId: channel.Id, Message: "test123123 @group1 @group2 blah blah blah", @@ -3043,7 +3056,7 @@ func TestFillInPostProps(t *testing.T) { channel := th.CreateChannel(t, th.BasicTeam) th.AddUserToChannel(t, guest, channel) - post1, err := th.App.CreatePost(th.Context, &model.Post{ + post1, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: guest.Id, ChannelId: channel.Id, Message: "test123123 @group1 @group2 blah blah blah", @@ -3175,7 +3188,7 @@ func TestFillInPostProps(t *testing.T) { dmChannelBetweenUser1AndUser2 := th.CreateDmChannel(t, user2) - post, err := th.App.CreatePost(th.Context, &model.Post{ + post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: dmChannelBetweenUser1AndUser2.Id, Message: "Testing out i should not be able to mention channel2 from team2? ~" + channel2.Name, @@ -3200,7 +3213,7 @@ func TestFillInPostProps(t *testing.T) { dmChannel := th.CreateDmChannel(t, user2) - post, err := th.App.CreatePost(th.Context, &model.Post{ + post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: dmChannel.Id, Message: "Check out ~" + channel.Name, @@ -3233,14 +3246,14 @@ func TestThreadMembership(t *testing.T) { channel := th.CreateChannel(t, th.BasicTeam) th.AddUserToChannel(t, user2, channel) - postRoot, err := th.App.CreatePost(th.Context, &model.Post{ + postRoot, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "root post", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, RootId: postRoot.Id, @@ -3257,14 +3270,14 @@ func TestThreadMembership(t *testing.T) { require.NoError(t, err2) require.Len(t, memberships, 1) - post2, err := th.App.CreatePost(th.Context, &model.Post{ + post2, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user2.Id, ChannelId: channel.Id, Message: "second post", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) - _, err = th.App.CreatePost(th.Context, &model.Post{ + _, _, err = th.App.CreatePost(th.Context, &model.Post{ UserId: user2.Id, ChannelId: channel.Id, RootId: post2.Id, @@ -3302,9 +3315,9 @@ func TestFollowThreadSkipsParticipants(t *testing.T) { appErr = th.App.JoinChannel(th.Context, channel, sysadmin.Id) require.Nil(t, appErr) - p1, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: user.Id, ChannelId: channel.Id, Message: "Hi @" + sysadmin.Username}, channel, model.CreatePostFlags{}) + p1, _, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: user.Id, ChannelId: channel.Id, Message: "Hi @" + sysadmin.Username}, channel, model.CreatePostFlags{}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "Hola"}, channel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "Hola"}, channel, model.CreatePostFlags{}) require.Nil(t, appErr) threadMembership, appErr := th.App.GetThreadMembershipForUser(user.Id, p1.Id) @@ -3313,7 +3326,7 @@ func TestFollowThreadSkipsParticipants(t *testing.T) { require.Nil(t, appErr) require.Len(t, thread.Participants, 1) // length should be 1, the original poster, since sysadmin was just mentioned but didn't post - _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: sysadmin.Id, ChannelId: channel.Id, Message: "sysadmin reply"}, channel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: sysadmin.Id, ChannelId: channel.Id, Message: "sysadmin reply"}, channel, model.CreatePostFlags{}) require.Nil(t, appErr) threadMembership, appErr = th.App.GetThreadMembershipForUser(user.Id, p1.Id) @@ -3364,12 +3377,12 @@ func TestAutofollowBasedOnRootPost(t *testing.T) { require.Nil(t, appErr) appErr = th.App.JoinChannel(th.Context, channel, user2.Id) require.Nil(t, appErr) - p1, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: user.Id, ChannelId: channel.Id, Message: "Hi @" + user2.Username}, channel, model.CreatePostFlags{}) + p1, _, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: user.Id, ChannelId: channel.Id, Message: "Hi @" + user2.Username}, channel, model.CreatePostFlags{}) require.Nil(t, appErr) m, err := th.App.GetThreadMembershipsForUser(user2.Id, th.BasicTeam.Id) require.NoError(t, err) require.Len(t, m, 0) - _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "Hola"}, channel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "Hola"}, channel, model.CreatePostFlags{}) require.Nil(t, appErr) m, err = th.App.GetThreadMembershipsForUser(user2.Id, th.BasicTeam.Id) require.NoError(t, err) @@ -3392,9 +3405,9 @@ func TestViewChannelShouldNotUpdateThreads(t *testing.T) { require.Nil(t, appErr) appErr = th.App.JoinChannel(th.Context, channel, user2.Id) require.Nil(t, appErr) - p1, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: user.Id, ChannelId: channel.Id, Message: "Hi @" + user2.Username}, channel, model.CreatePostFlags{}) + p1, _, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: user.Id, ChannelId: channel.Id, Message: "Hi @" + user2.Username}, channel, model.CreatePostFlags{}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "Hola"}, channel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "Hola"}, channel, model.CreatePostFlags{}) require.Nil(t, appErr) m, err := th.App.GetThreadMembershipsForUser(user2.Id, th.BasicTeam.Id) require.NoError(t, err) @@ -3429,14 +3442,14 @@ func TestCollapsedThreadFetch(t *testing.T) { require.Nil(t, appErr) }() - postRoot, appErr := th.App.CreatePost(th.Context, &model.Post{ + postRoot, _, appErr := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "root post", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{ + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, RootId: postRoot.Id, @@ -3472,7 +3485,7 @@ func TestCollapsedThreadFetch(t *testing.T) { require.Nil(t, appErr) }() - postRoot, err := th.App.CreatePost(th.Context, &model.Post{ + postRoot, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "root post", @@ -3491,7 +3504,7 @@ func TestCollapsedThreadFetch(t *testing.T) { require.NotPanics(t, func() { // We're only testing that this doesn't panic, not checking the error // #nosec G104 - purposely not checking error as we're in a NotPanics block - _, _ = th.App.CreatePost(th.Context, &model.Post{ + _, _, _ = th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, RootId: postRoot.Id, @@ -3527,14 +3540,14 @@ func TestCollapsedThreadFetch(t *testing.T) { th.LinkUserToTeam(t, user3, th.BasicTeam) th.AddUserToChannel(t, user3, channel) - postRoot, appErr := th.App.CreatePost(th.Context, &model.Post{ + postRoot, _, appErr := th.App.CreatePost(th.Context, &model.Post{ UserId: user1.Id, ChannelId: channel.Id, Message: "root post", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{ + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{ UserId: user3.Id, ChannelId: channel.Id, RootId: postRoot.Id, @@ -3598,7 +3611,7 @@ func TestSharedChannelSyncForPostActions(t *testing.T) { channel := th.CreateChannel(t, th.BasicTeam, WithShared(true)) - _, err := th.App.CreatePost(th.Context, &model.Post{ + _, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user.Id, ChannelId: channel.Id, Message: "Hello folks", @@ -3621,14 +3634,14 @@ func TestSharedChannelSyncForPostActions(t *testing.T) { channel := th.CreateChannel(t, th.BasicTeam, WithShared(true)) - post, err := th.App.CreatePost(th.Context, &model.Post{ + post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user.Id, ChannelId: channel.Id, Message: "Hello folks", }, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err, "Creating a post should not error") - _, err = th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true}) + _, _, err = th.App.UpdatePost(th.Context, post, &model.UpdatePostOptions{SafeUpdate: true}) require.Nil(t, err, "Updating a post should not error") require.Len(t, sharedChannelService.channelNotifications, 2) @@ -3648,7 +3661,7 @@ func TestSharedChannelSyncForPostActions(t *testing.T) { channel := th.CreateChannel(t, th.BasicTeam, WithShared(true)) - post, err := th.App.CreatePost(th.Context, &model.Post{ + post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user.Id, ChannelId: channel.Id, Message: "Hello folks", @@ -3682,11 +3695,11 @@ func TestAutofollowOnPostingAfterUnfollow(t *testing.T) { require.Nil(t, appErr) appErr = th.App.JoinChannel(th.Context, channel, user2.Id) require.Nil(t, appErr) - p1, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: user.Id, ChannelId: channel.Id, Message: "Hi @" + user2.Username}, channel, model.CreatePostFlags{}) + p1, _, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: user.Id, ChannelId: channel.Id, Message: "Hi @" + user2.Username}, channel, model.CreatePostFlags{}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user2.Id, ChannelId: channel.Id, Message: "Hola"}, channel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user2.Id, ChannelId: channel.Id, Message: "Hola"}, channel, model.CreatePostFlags{}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "reply"}, channel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "reply"}, channel, model.CreatePostFlags{}) require.Nil(t, appErr) // unfollow thread @@ -3697,7 +3710,7 @@ func TestAutofollowOnPostingAfterUnfollow(t *testing.T) { require.NoError(t, err) require.False(t, m.Following) - _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "another reply"}, channel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "another reply"}, channel, model.CreatePostFlags{}) require.Nil(t, appErr) // User should be following thread after posting in it, even after previously @@ -3713,7 +3726,7 @@ func TestGetPostIfAuthorized(t *testing.T) { t.Run("Private channel", func(t *testing.T) { privateChannel := th.CreatePrivateChannel(t, th.BasicTeam) - post, err := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, ChannelId: privateChannel.Id, Message: "Hello"}, privateChannel, model.CreatePostFlags{}) + post, _, err := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, ChannelId: privateChannel.Id, Message: "Hello"}, privateChannel, model.CreatePostFlags{}) require.Nil(t, err) require.NotNil(t, post) @@ -3726,17 +3739,17 @@ func TestGetPostIfAuthorized(t *testing.T) { require.NotNil(t, session2) // User is not authorized to get post - _, err = th.App.GetPostIfAuthorized(th.Context, post.Id, session2, false) + _, err, _ = th.App.GetPostIfAuthorized(th.Context, post.Id, session2, false) require.NotNil(t, err) // User is authorized to get post - _, err = th.App.GetPostIfAuthorized(th.Context, post.Id, session1, false) + _, err, _ = th.App.GetPostIfAuthorized(th.Context, post.Id, session1, false) require.Nil(t, err) }) t.Run("Public channel", func(t *testing.T) { publicChannel := th.CreateChannel(t, th.BasicTeam) - post, err := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, ChannelId: publicChannel.Id, Message: "Hello"}, publicChannel, model.CreatePostFlags{}) + post, _, err := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser.Id, ChannelId: publicChannel.Id, Message: "Hello"}, publicChannel, model.CreatePostFlags{}) require.Nil(t, err) require.NotNil(t, post) @@ -3749,11 +3762,11 @@ func TestGetPostIfAuthorized(t *testing.T) { require.NotNil(t, session2) // User is authorized to get post - _, err = th.App.GetPostIfAuthorized(th.Context, post.Id, session2, false) + _, err, _ = th.App.GetPostIfAuthorized(th.Context, post.Id, session2, false) require.Nil(t, err) // User is authorized to get post - _, err = th.App.GetPostIfAuthorized(th.Context, post.Id, session1, false) + _, err, _ = th.App.GetPostIfAuthorized(th.Context, post.Id, session1, false) require.Nil(t, err) th.App.UpdateConfig(func(c *model.Config) { @@ -3762,11 +3775,11 @@ func TestGetPostIfAuthorized(t *testing.T) { }) // User is not authorized to get post - _, err = th.App.GetPostIfAuthorized(th.Context, post.Id, session2, false) + _, err, _ = th.App.GetPostIfAuthorized(th.Context, post.Id, session2, false) require.NotNil(t, err) // User is authorized to get post - _, err = th.App.GetPostIfAuthorized(th.Context, post.Id, session1, false) + _, err, _ = th.App.GetPostIfAuthorized(th.Context, post.Id, session1, false) require.Nil(t, err) }) } @@ -3787,9 +3800,9 @@ func TestShouldNotRefollowOnOthersReply(t *testing.T) { require.Nil(t, appErr) appErr = th.App.JoinChannel(th.Context, channel, user2.Id) require.Nil(t, appErr) - p1, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: user.Id, ChannelId: channel.Id, Message: "Hi @" + user2.Username}, channel, model.CreatePostFlags{}) + p1, _, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: user.Id, ChannelId: channel.Id, Message: "Hi @" + user2.Username}, channel, model.CreatePostFlags{}) require.Nil(t, appErr) - _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user2.Id, ChannelId: channel.Id, Message: "Hola"}, channel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user2.Id, ChannelId: channel.Id, Message: "Hola"}, channel, model.CreatePostFlags{}) require.Nil(t, appErr) // User2 unfollows thread @@ -3801,7 +3814,7 @@ func TestShouldNotRefollowOnOthersReply(t *testing.T) { require.False(t, m.Following) // user posts in the thread - _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "another reply"}, channel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "another reply"}, channel, model.CreatePostFlags{}) require.Nil(t, appErr) // User2 should still not be following the thread because they manually @@ -3811,7 +3824,7 @@ func TestShouldNotRefollowOnOthersReply(t *testing.T) { require.False(t, m.Following) // user posts in the thread mentioning user2 - _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "reply with mention @" + user2.Username}, channel, model.CreatePostFlags{}) + _, _, appErr = th.App.CreatePost(th.Context, &model.Post{RootId: p1.Id, UserId: user.Id, ChannelId: channel.Id, Message: "reply with mention @" + user2.Username}, channel, model.CreatePostFlags{}) require.Nil(t, appErr) // User2 should now be following the thread because they were explicitly mentioned @@ -3942,14 +3955,14 @@ func TestGetEditHistoryForPost(t *testing.T) { UserId: th.BasicUser.Id, } - rpost, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + rpost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) // update the post message patch := &model.PostPatch{ Message: model.NewPointer("new message edited"), } - _, err1 := th.App.PatchPost(th.Context, rpost.Id, patch, nil) + _, _, err1 := th.App.PatchPost(th.Context, rpost.Id, patch, nil) require.Nil(t, err1) // update the post message again @@ -3957,7 +3970,7 @@ func TestGetEditHistoryForPost(t *testing.T) { Message: model.NewPointer("new message edited again"), } - _, err2 := th.App.PatchPost(th.Context, rpost.Id, patch, nil) + _, _, err2 := th.App.PatchPost(th.Context, rpost.Id, patch, nil) require.Nil(t, err2) t.Run("should return the edit history", func(t *testing.T) { @@ -3987,25 +4000,25 @@ func TestGetEditHistoryForPost(t *testing.T) { FileIds: model.StringArray{fileInfo.Id}, } - _, err = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + _, _, err = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) patch := &model.PostPatch{ Message: model.NewPointer("new message edited"), } - _, appErr := th.App.PatchPost(th.Context, post.Id, patch, nil) + _, _, appErr := th.App.PatchPost(th.Context, post.Id, patch, nil) require.Nil(t, appErr) patch = &model.PostPatch{ Message: model.NewPointer("new message edited 2"), } - _, appErr = th.App.PatchPost(th.Context, post.Id, patch, nil) + _, _, appErr = th.App.PatchPost(th.Context, post.Id, patch, nil) require.Nil(t, appErr) patch = &model.PostPatch{ Message: model.NewPointer("new message edited 3"), } - _, appErr = th.App.PatchPost(th.Context, post.Id, patch, nil) + _, _, appErr = th.App.PatchPost(th.Context, post.Id, patch, nil) require.Nil(t, appErr) edits, err := th.App.GetEditHistoryForPost(post.Id) @@ -4033,25 +4046,25 @@ func TestGetEditHistoryForPost(t *testing.T) { FileIds: model.StringArray{fileInfo.Id}, } - _, appErr = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + _, _, appErr = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) patch := &model.PostPatch{ Message: model.NewPointer("new message edited"), } - _, appErr = th.App.PatchPost(th.Context, post.Id, patch, nil) + _, _, appErr = th.App.PatchPost(th.Context, post.Id, patch, nil) require.Nil(t, appErr) patch = &model.PostPatch{ Message: model.NewPointer("new message edited 2"), } - _, appErr = th.App.PatchPost(th.Context, post.Id, patch, nil) + _, _, appErr = th.App.PatchPost(th.Context, post.Id, patch, nil) require.Nil(t, appErr) patch = &model.PostPatch{ Message: model.NewPointer("new message edited 3"), } - _, appErr = th.App.PatchPost(th.Context, post.Id, patch, nil) + _, _, appErr = th.App.PatchPost(th.Context, post.Id, patch, nil) require.Nil(t, appErr) // now delete the file info, and it should still be include in edit history metadata @@ -4087,7 +4100,7 @@ func TestCopyWranglerPostlist(t *testing.T) { UserId: th.BasicUser.Id, FileIds: []string{fileInfo.Id}, } - rootPost, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + rootPost, _, err := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, err) // Add a reaction to the post @@ -4111,7 +4124,7 @@ func TestCopyWranglerPostlist(t *testing.T) { Posts: []*model.Post{rootPost}, FileAttachmentCount: 1, } - newRootPost, err := th.App.CopyWranglerPostlist(th.Context, wpl, targetChannel) + newRootPost, _, err := th.App.CopyWranglerPostlist(th.Context, wpl, targetChannel) require.Nil(t, err) // Check that the new post has the same message and file attachment @@ -4278,7 +4291,7 @@ func TestPermanentDeletePost(t *testing.T) { FileIds: []string{info1.Id}, } - post, err = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + post, _, err = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) assert.Nil(t, err) // Delete the post. @@ -4317,7 +4330,7 @@ func TestPermanentDeletePost(t *testing.T) { FileIds: []string{info1.Id}, } - post, appErr = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + post, _, appErr = th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) assert.Nil(t, appErr) infos, err := th.App.Srv().Store().FileInfo().GetForPost(post.Id, true, true, false) @@ -4372,7 +4385,7 @@ func TestPermanentDeletePost(t *testing.T) { } post.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(model.DefaultExpirySeconds*1000)) - post, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) + post, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{SetOnline: true}) require.Nil(t, appErr) require.Equal(t, model.PostTypeBurnOnRead, post.Type) @@ -4539,6 +4552,145 @@ func TestPopulateEditHistoryFileMetadata(t *testing.T) { }) } +func TestFilterPostsByChannelPermissions(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.GuestAccountsSettings.Enable = true + }) + + guestUser := th.CreateGuest(t) + _, _, appErr := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, guestUser.Id, "") + require.Nil(t, appErr) + + privateChannel := th.CreatePrivateChannel(t, th.BasicTeam) + + _, appErr = th.App.AddUserToChannel(th.Context, guestUser, privateChannel, false) + require.Nil(t, appErr) + _, appErr = th.App.AddUserToChannel(th.Context, guestUser, th.BasicChannel, false) + require.Nil(t, appErr) + + post1 := th.CreatePost(t, th.BasicChannel) + post2 := th.CreatePost(t, privateChannel) + post3 := th.CreatePost(t, th.BasicChannel) + + t.Run("should filter posts when user has read_channel_content permission", func(t *testing.T) { + postList := model.NewPostList() + postList.Posts[post1.Id] = post1 + postList.Posts[post2.Id] = post2 + postList.Posts[post3.Id] = post3 + postList.Order = []string{post1.Id, post2.Id, post3.Id} + + allPostHaveMembership, appErr := th.App.FilterPostsByChannelPermissions(th.Context, postList, th.BasicUser.Id) + require.Nil(t, appErr) + require.Len(t, postList.Posts, 3) + require.Len(t, postList.Order, 3) + require.True(t, allPostHaveMembership) + }) + + t.Run("should filter posts when guest has read_channel_content permission", func(t *testing.T) { + postList := model.NewPostList() + postList.Posts[post1.Id] = post1 + postList.Posts[post2.Id] = post2 + postList.Posts[post3.Id] = post3 + postList.Order = []string{post1.Id, post2.Id, post3.Id} + + allPostHaveMembership, appErr := th.App.FilterPostsByChannelPermissions(th.Context, postList, guestUser.Id) + require.Nil(t, appErr) + require.Len(t, postList.Posts, 3) + require.Len(t, postList.Order, 3) + require.True(t, allPostHaveMembership) + }) + + t.Run("should filter posts when guest does not have read_channel_content permission", func(t *testing.T) { + channelGuestRole, appErr := th.App.GetRoleByName(th.Context, model.ChannelGuestRoleId) + require.Nil(t, appErr) + + originalPermissions := make([]string, len(channelGuestRole.Permissions)) + copy(originalPermissions, channelGuestRole.Permissions) + + newPermissions := []string{} + for _, perm := range channelGuestRole.Permissions { + if perm != model.PermissionReadChannelContent.Id && perm != model.PermissionReadChannel.Id { + newPermissions = append(newPermissions, perm) + } + } + + _, appErr = th.App.PatchRole(channelGuestRole, &model.RolePatch{ + Permissions: &newPermissions, + }) + require.Nil(t, appErr) + + defer func() { + _, err := th.App.PatchRole(channelGuestRole, &model.RolePatch{ + Permissions: &originalPermissions, + }) + require.Nil(t, err) + }() + + postList := model.NewPostList() + postList.Posts[post1.Id] = post1 + postList.Posts[post2.Id] = post2 + postList.Posts[post3.Id] = post3 + postList.Order = []string{post1.Id, post2.Id, post3.Id} + + allPostHaveMembership, appErr := th.App.FilterPostsByChannelPermissions(th.Context, postList, guestUser.Id) + require.Nil(t, appErr) + require.Len(t, postList.Posts, 0) + require.Len(t, postList.Order, 0) + require.True(t, allPostHaveMembership) + }) + + t.Run("should handle empty post list", func(t *testing.T) { + postList := model.NewPostList() + allPostHaveMembership, appErr := th.App.FilterPostsByChannelPermissions(th.Context, postList, th.BasicUser.Id) + require.Nil(t, appErr) + require.Len(t, postList.Posts, 0) + require.Len(t, postList.Order, 0) + require.True(t, allPostHaveMembership) + }) + + t.Run("should handle nil post list", func(t *testing.T) { + _, appErr := th.App.FilterPostsByChannelPermissions(th.Context, nil, th.BasicUser.Id) + require.Nil(t, appErr) + }) + + t.Run("should handle posts with empty channel IDs", func(t *testing.T) { + postList := model.NewPostList() + postWithoutChannel := &model.Post{ + Id: model.NewId(), + ChannelId: "", + Message: "test", + } + postList.Posts[postWithoutChannel.Id] = postWithoutChannel + postList.Order = []string{postWithoutChannel.Id} + + allPostHaveMembership, appErr := th.App.FilterPostsByChannelPermissions(th.Context, postList, th.BasicUser.Id) + require.Nil(t, appErr) + require.Len(t, postList.Posts, 0) + require.Len(t, postList.Order, 0) + require.True(t, allPostHaveMembership) + }) + + t.Run("should handle posts from non-existent channels", func(t *testing.T) { + postList := model.NewPostList() + postWithInvalidChannel := &model.Post{ + Id: model.NewId(), + ChannelId: model.NewId(), + Message: "test", + } + postList.Posts[postWithInvalidChannel.Id] = postWithInvalidChannel + postList.Order = []string{postWithInvalidChannel.Id} + + allPostHaveMembership, appErr := th.App.FilterPostsByChannelPermissions(th.Context, postList, th.BasicUser.Id) + require.Nil(t, appErr) + require.Len(t, postList.Posts, 0) + require.Len(t, postList.Order, 0) + require.True(t, allPostHaveMembership) + }) +} + func TestRevealPost(t *testing.T) { os.Setenv("MM_FEATUREFLAGS_BURNONREAD", "true") t.Cleanup(func() { @@ -4557,7 +4709,7 @@ func TestRevealPost(t *testing.T) { } post.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(model.DefaultExpirySeconds*1000)) - createdPost, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) + createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) require.NotNil(t, createdPost) return createdPost @@ -4570,7 +4722,7 @@ func TestRevealPost(t *testing.T) { UserId: th.BasicUser.Id, Message: "regular message", } - createdPost, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) + createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) require.NotNil(t, createdPost) return createdPost @@ -4603,7 +4755,7 @@ func TestRevealPost(t *testing.T) { } // First save the post normally (which will add expire_at automatically) - createdPost, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) + createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) // Now manually remove the expire_at prop to test missing prop scenario @@ -4738,7 +4890,7 @@ func TestRevealPost(t *testing.T) { } post.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(model.DefaultExpirySeconds*1000)) - createdPost, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) + createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) require.NotNil(t, createdPost) @@ -4779,7 +4931,7 @@ func TestBurnPost(t *testing.T) { } post.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(model.DefaultExpirySeconds*1000)) - createdPost, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) + createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) require.NotNil(t, createdPost) return createdPost @@ -4792,7 +4944,7 @@ func TestBurnPost(t *testing.T) { UserId: th.BasicUser.Id, Message: "regular message", } - createdPost, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) + createdPost, _, appErr := th.App.CreatePost(th.Context, post, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) require.NotNil(t, createdPost) return createdPost @@ -4896,7 +5048,7 @@ func TestGetFlaggedPostsWithExpiredBurnOnRead(t *testing.T) { } borPost.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(10*1000)) // 10 seconds - createdPost, appErr := th.App.CreatePost(th.Context, borPost, th.BasicChannel, model.CreatePostFlags{}) + createdPost, _, appErr := th.App.CreatePost(th.Context, borPost, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) require.NotNil(t, createdPost) @@ -4951,7 +5103,7 @@ func TestGetFlaggedPostsWithExpiredBurnOnRead(t *testing.T) { } borPost.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(10*1000)) - createdPost, appErr := th.App.CreatePost(th.Context, borPost, th.BasicChannel, model.CreatePostFlags{}) + createdPost, _, appErr := th.App.CreatePost(th.Context, borPost, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) // User2 reveals and flags the post @@ -4993,7 +5145,7 @@ func TestGetFlaggedPostsWithExpiredBurnOnRead(t *testing.T) { } borPost.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(10*1000)) - createdPost, appErr := th.App.CreatePost(th.Context, borPost, th.BasicChannel, model.CreatePostFlags{}) + createdPost, _, appErr := th.App.CreatePost(th.Context, borPost, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) // User2 reveals and flags the post @@ -5035,7 +5187,7 @@ func TestGetFlaggedPostsWithExpiredBurnOnRead(t *testing.T) { } borPost.AddProp(model.PostPropsExpireAt, model.GetMillis()+int64(3600*1000)) // 1 hour - createdPost, appErr := th.App.CreatePost(th.Context, borPost, th.BasicChannel, model.CreatePostFlags{}) + createdPost, _, appErr := th.App.CreatePost(th.Context, borPost, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) // User2 reveals and flags the post diff --git a/server/channels/app/reaction_test.go b/server/channels/app/reaction_test.go index d9a26325546..9f729a561a0 100644 --- a/server/channels/app/reaction_test.go +++ b/server/channels/app/reaction_test.go @@ -131,7 +131,7 @@ func TestSaveReactionForPost(t *testing.T) { ChannelId: dmChannel.Id, Message: "test post", } - post, err = th.App.CreatePost(th.Context, post, dmChannel, model.CreatePostFlags{}) + post, _, err = th.App.CreatePost(th.Context, post, dmChannel, model.CreatePostFlags{}) require.Nil(t, err) reaction := &model.Reaction{ @@ -191,7 +191,7 @@ func TestDeleteReactionForPostWithRestrictedDM(t *testing.T) { ChannelId: dmChannel.Id, Message: "test post", } - post, err = th.App.CreatePost(th.Context, post, dmChannel, model.CreatePostFlags{}) + post, _, err = th.App.CreatePost(th.Context, post, dmChannel, model.CreatePostFlags{}) require.Nil(t, err) reaction := &model.Reaction{ @@ -226,7 +226,7 @@ func TestSharedChannelSyncForReactionActions(t *testing.T) { channel := th.CreateChannel(t, th.BasicTeam, WithShared(true)) - post, err := th.App.CreatePost(th.Context, &model.Post{ + post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user.Id, ChannelId: channel.Id, Message: "Hello folks", @@ -258,7 +258,7 @@ func TestSharedChannelSyncForReactionActions(t *testing.T) { channel := th.CreateChannel(t, th.BasicTeam, WithShared(true)) - post, err := th.App.CreatePost(th.Context, &model.Post{ + post, _, err := th.App.CreatePost(th.Context, &model.Post{ UserId: user.Id, ChannelId: channel.Id, Message: "Hello folks", diff --git a/server/channels/app/recap.go b/server/channels/app/recap.go new file mode 100644 index 00000000000..743dc576c0c --- /dev/null +++ b/server/channels/app/recap.go @@ -0,0 +1,301 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "net/http" + "strings" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" +) + +// CreateRecap creates a new recap job for the specified channels +func (a *App) CreateRecap(rctx request.CTX, title string, channelIDs []string, agentID string) (*model.Recap, *model.AppError) { + userID := rctx.Session().UserId + + // Validate user is member of all channels + for _, channelID := range channelIDs { + if ok, _ := a.HasPermissionToChannel(rctx, userID, channelID, model.PermissionReadChannel); !ok { + return nil, model.NewAppError("CreateRecap", "app.recap.permission_denied", nil, "", http.StatusForbidden) + } + } + + timeNow := model.GetMillis() + + // Create recap record + recap := &model.Recap{ + Id: model.NewId(), + UserId: userID, + Title: title, + CreateAt: timeNow, + UpdateAt: timeNow, + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 0, + Status: model.RecapStatusPending, + BotID: agentID, + } + + savedRecap, err := a.Srv().Store().Recap().SaveRecap(recap) + if err != nil { + return nil, model.NewAppError("CreateRecap", "app.recap.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + // Create background job + jobData := map[string]string{ + "recap_id": recap.Id, + "user_id": userID, + "channel_ids": strings.Join(channelIDs, ","), + "agent_id": agentID, + } + + _, jobErr := a.CreateJob(rctx, &model.Job{ + Type: model.JobTypeRecap, + Data: jobData, + }) + + if jobErr != nil { + return nil, jobErr + } + + return savedRecap, nil +} + +// GetRecap retrieves a recap by ID +func (a *App) GetRecap(rctx request.CTX, recapID string) (*model.Recap, *model.AppError) { + recap, err := a.Srv().Store().Recap().GetRecap(recapID) + if err != nil { + return nil, model.NewAppError("GetRecap", "app.recap.get.app_error", nil, "", http.StatusNotFound).Wrap(err) + } + + // Load channels + channels, err := a.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID) + if err != nil { + return nil, model.NewAppError("GetRecap", "app.recap.get_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + recap.Channels = channels + + return recap, nil +} + +// GetRecapsForUser retrieves all recaps for a user +func (a *App) GetRecapsForUser(rctx request.CTX, page, perPage int) ([]*model.Recap, *model.AppError) { + userID := rctx.Session().UserId + recaps, err := a.Srv().Store().Recap().GetRecapsForUser(userID, page, perPage) + if err != nil { + return nil, model.NewAppError("GetRecapsForUser", "app.recap.list.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return recaps, nil +} + +// MarkRecapAsRead marks a recap as read +func (a *App) MarkRecapAsRead(rctx request.CTX, recap *model.Recap) (*model.Recap, *model.AppError) { + // Mark as read + if markErr := a.Srv().Store().Recap().MarkRecapAsRead(recap.Id); markErr != nil { + return nil, model.NewAppError("MarkRecapAsRead", "app.recap.mark_read.app_error", nil, "", http.StatusInternalServerError).Wrap(markErr) + } + + // Update the passed recap with read timestamp + recap.ReadAt = model.GetMillis() + recap.UpdateAt = recap.ReadAt + + // Load channels if not already loaded + if recap.Channels == nil { + channels, err := a.Srv().Store().Recap().GetRecapChannelsByRecapId(recap.Id) + if err != nil { + return nil, model.NewAppError("MarkRecapAsRead", "app.recap.get_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + recap.Channels = channels + } + + return recap, nil +} + +// RegenerateRecap regenerates an existing recap +func (a *App) RegenerateRecap(rctx request.CTX, userID string, recap *model.Recap) (*model.Recap, *model.AppError) { + recapID := recap.Id + + // Get existing recap channels to extract channel IDs + channels, err := a.Srv().Store().Recap().GetRecapChannelsByRecapId(recapID) + if err != nil { + return nil, model.NewAppError("RegenerateRecap", "app.recap.get_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + // Extract channel IDs + channelIDs := make([]string, len(channels)) + for i, channel := range channels { + channelIDs[i] = channel.ChannelId + } + + // Delete existing recap channels + if deleteErr := a.Srv().Store().Recap().DeleteRecapChannels(recapID); deleteErr != nil { + return nil, model.NewAppError("RegenerateRecap", "app.recap.delete_channels.app_error", nil, "", http.StatusInternalServerError).Wrap(deleteErr) + } + + // Update recap status to pending and reset read status + recap.Status = model.RecapStatusPending + recap.ReadAt = 0 + recap.UpdateAt = model.GetMillis() + recap.TotalMessageCount = 0 + + if _, updateErr := a.Srv().Store().Recap().UpdateRecap(recap); updateErr != nil { + return nil, model.NewAppError("RegenerateRecap", "app.recap.update.app_error", nil, "", http.StatusInternalServerError).Wrap(updateErr) + } + + // Create new job with same parameters + jobData := map[string]string{ + "recap_id": recapID, + "user_id": userID, + "channel_ids": strings.Join(channelIDs, ","), + "agent_id": recap.BotID, + } + + _, jobErr := a.CreateJob(rctx, &model.Job{ + Type: model.JobTypeRecap, + Data: jobData, + }) + + if jobErr != nil { + return nil, jobErr + } + + // Return updated recap + updatedRecap, getErr := a.GetRecap(rctx, recapID) + if getErr != nil { + return nil, getErr + } + + return updatedRecap, nil +} + +// DeleteRecap deletes a recap (soft delete) +func (a *App) DeleteRecap(rctx request.CTX, recapID string) *model.AppError { + // Delete recap + if deleteErr := a.Srv().Store().Recap().DeleteRecap(recapID); deleteErr != nil { + return model.NewAppError("DeleteRecap", "app.recap.delete.app_error", nil, "", http.StatusInternalServerError).Wrap(deleteErr) + } + + return nil +} + +// ProcessRecapChannel processes a single channel for a recap, fetching posts, summarizing them, +// and saving the recap channel record. Returns the number of messages processed. +func (a *App) ProcessRecapChannel(rctx request.CTX, recapID, channelID, userID, agentID string) (*model.RecapChannelResult, *model.AppError) { + result := &model.RecapChannelResult{ + ChannelID: channelID, + Success: false, + } + + // Get channel info + channel, err := a.GetChannel(rctx, channelID) + if err != nil { + return result, model.NewAppError("ProcessRecapChannel", "app.recap.get_channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + // Get user's last viewed timestamp + lastViewedAt, lastViewedErr := a.Srv().Store().Channel().GetMemberLastViewedAt(rctx, channelID, userID) + if lastViewedErr != nil { + return result, model.NewAppError("ProcessRecapChannel", "app.recap.get_last_viewed.app_error", nil, "", http.StatusInternalServerError).Wrap(lastViewedErr) + } + + // Fetch posts for recap + posts, postsErr := a.fetchPostsForRecap(rctx, channelID, lastViewedAt, 100) + if postsErr != nil { + return result, postsErr + } + + // No posts to summarize - return success with 0 messages + if len(posts) == 0 { + result.Success = true + return result, nil + } + + // Get team info for permalink generation + team, teamErr := a.GetTeam(channel.TeamId) + if teamErr != nil { + return result, model.NewAppError("ProcessRecapChannel", "app.recap.get_team.app_error", nil, "", http.StatusInternalServerError).Wrap(teamErr) + } + + // Summarize posts + summary, err := a.SummarizePosts(rctx, userID, posts, channel.DisplayName, team.Name, agentID) + if err != nil { + return result, err + } + + // Save recap channel + recapChannel := &model.RecapChannel{ + Id: model.NewId(), + RecapId: recapID, + ChannelId: channelID, + ChannelName: channel.DisplayName, + Highlights: summary.Highlights, + ActionItems: summary.ActionItems, + SourcePostIds: extractPostIDs(posts), + CreateAt: model.GetMillis(), + } + + if err := a.Srv().Store().Recap().SaveRecapChannel(recapChannel); err != nil { + return result, model.NewAppError("ProcessRecapChannel", "app.recap.save_channel.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + result.MessageCount = len(posts) + result.Success = true + return result, nil +} + +// fetchPostsForRecap fetches posts for a channel after the given timestamp and enriches them with user information +func (a *App) fetchPostsForRecap(rctx request.CTX, channelID string, lastViewedAt int64, limit int) ([]*model.Post, *model.AppError) { + // Get posts after lastViewedAt + options := model.GetPostsSinceOptions{ + ChannelId: channelID, + Time: lastViewedAt, + } + + postList, err := a.GetPostsSince(rctx, options) + if err != nil { + return nil, err + } + + if len(postList.Posts) == 0 { + // If there are no unread posts, get the most recent 15 posts to include in the recap + postList, err = a.GetPosts(rctx, channelID, 0, 20) + if err != nil { + return nil, err + } + } + + // Convert to slice and limit + posts := make([]*model.Post, 0, len(postList.Posts)) + for _, postID := range postList.Order { + if post, ok := postList.Posts[postID]; ok { + posts = append(posts, post) + if len(posts) >= limit { + break + } + } + } + + // Enrich with usernames + for _, post := range posts { + user, _ := a.GetUser(post.UserId) + if user != nil { + if post.Props == nil { + post.Props = make(model.StringInterface) + } + post.AddProp("username", user.Username) + } + } + + return posts, nil +} + +// extractPostIDs extracts post IDs from a slice of posts +func extractPostIDs(posts []*model.Post) []string { + ids := make([]string, len(posts)) + for i, post := range posts { + ids[i] = post.Id + } + return ids +} diff --git a/server/channels/app/recap_test.go b/server/channels/app/recap_test.go new file mode 100644 index 00000000000..2edb0db43fd --- /dev/null +++ b/server/channels/app/recap_test.go @@ -0,0 +1,307 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "os" + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateRecap(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS") + + th := Setup(t).InitBasic(t) + + t.Run("create recap with valid channels", func(t *testing.T) { + channel2 := th.CreateChannel(t, th.BasicTeam) + channelIds := []string{th.BasicChannel.Id, channel2.Id} + + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + recap, err := th.App.CreateRecap(ctx, "My Test Recap", channelIds, "test-agent-id") + require.Nil(t, err) + require.NotNil(t, recap) + assert.Equal(t, th.BasicUser.Id, recap.UserId) + assert.Equal(t, model.RecapStatusPending, recap.Status) + assert.Equal(t, "My Test Recap", recap.Title) + }) + + t.Run("create recap with channel user is not member of", func(t *testing.T) { + // Create a private channel and add only BasicUser2 + privateChannel := th.CreatePrivateChannel(t, th.BasicTeam) + // Remove BasicUser if they were added automatically + _ = th.App.RemoveUserFromChannel(th.Context, th.BasicUser.Id, "", privateChannel) + // Ensure BasicUser2 is a member instead + th.AddUserToChannel(t, th.BasicUser2, privateChannel) + + // Try to create recap as BasicUser who is not a member + channelIds := []string{privateChannel.Id} + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + recap, err := th.App.CreateRecap(ctx, "Test Recap", channelIds, "test-agent-id") + require.NotNil(t, err) + assert.Nil(t, recap) + assert.Equal(t, "app.recap.permission_denied", err.Id) + }) +} + +func TestGetRecap(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS") + + th := Setup(t).InitBasic(t) + + t.Run("get recap by owner", func(t *testing.T) { + recap := &model.Recap{ + Id: model.NewId(), + UserId: th.BasicUser.Id, + Title: "Test Recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 10, + Status: model.RecapStatusCompleted, + } + + _, err := th.App.Srv().Store().Recap().SaveRecap(recap) + require.NoError(t, err) + + // Create recap channel + recapChannel := &model.RecapChannel{ + Id: model.NewId(), + RecapId: recap.Id, + ChannelId: th.BasicChannel.Id, + ChannelName: th.BasicChannel.DisplayName, + Highlights: []string{"Test highlight"}, + ActionItems: []string{"Test action"}, + SourcePostIds: []string{model.NewId()}, + CreateAt: model.GetMillis(), + } + + err = th.App.Srv().Store().Recap().SaveRecapChannel(recapChannel) + require.NoError(t, err) + + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + retrievedRecap, appErr := th.App.GetRecap(ctx, recap.Id) + require.Nil(t, appErr) + require.NotNil(t, retrievedRecap) + assert.Equal(t, recap.Id, retrievedRecap.Id) + assert.Len(t, retrievedRecap.Channels, 1) + assert.Equal(t, recapChannel.ChannelName, retrievedRecap.Channels[0].ChannelName) + }) + + t.Run("get recap by non-owner", func(t *testing.T) { + recap := &model.Recap{ + Id: model.NewId(), + UserId: th.BasicUser.Id, + Title: "Test Recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 10, + Status: model.RecapStatusCompleted, + } + + _, err := th.App.Srv().Store().Recap().SaveRecap(recap) + require.NoError(t, err) + + // Try to get as a different user - create context with BasicUser2's session + ctx := request.TestContext(t).WithSession(&model.Session{UserId: th.BasicUser2.Id}) + retrievedRecap, appErr := th.App.GetRecap(ctx, recap.Id) + // Permissions are now checked in API layer, so App layer should return the recap + require.Nil(t, appErr) + require.NotNil(t, retrievedRecap) + assert.Equal(t, recap.Id, retrievedRecap.Id) + }) +} + +func TestGetRecapsForUser(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS") + + th := Setup(t).InitBasic(t) + + t.Run("get recaps for user", func(t *testing.T) { + // Create multiple recaps for the user + for range 5 { + recap := &model.Recap{ + Id: model.NewId(), + UserId: th.BasicUser.Id, + Title: "Test Recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 10, + Status: model.RecapStatusCompleted, + } + + _, err := th.App.Srv().Store().Recap().SaveRecap(recap) + require.NoError(t, err) + } + + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + recaps, err := th.App.GetRecapsForUser(ctx, 0, 10) + require.Nil(t, err) + assert.Len(t, recaps, 5) + }) + + t.Run("pagination works correctly", func(t *testing.T) { + userId := model.NewId() + + // Create context with the test user's session + ctx := request.TestContext(t).WithSession(&model.Session{UserId: userId}) + + // Create 15 recaps + for range 15 { + recap := &model.Recap{ + Id: model.NewId(), + UserId: userId, + Title: "Test Recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 10, + Status: model.RecapStatusCompleted, + } + + _, err := th.App.Srv().Store().Recap().SaveRecap(recap) + require.NoError(t, err) + } + + // Get first page + recapsPage1, err := th.App.GetRecapsForUser(ctx, 0, 10) + require.Nil(t, err) + assert.Len(t, recapsPage1, 10) + + // Get second page + recapsPage2, err := th.App.GetRecapsForUser(ctx, 1, 10) + require.Nil(t, err) + assert.Len(t, recapsPage2, 5) + }) +} + +func TestMarkRecapAsRead(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS") + + th := Setup(t).InitBasic(t) + + t.Run("mark recap as read by owner", func(t *testing.T) { + recap := &model.Recap{ + Id: model.NewId(), + UserId: th.BasicUser.Id, + Title: "Test Recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 10, + Status: model.RecapStatusCompleted, + } + + savedRecap, err := th.App.Srv().Store().Recap().SaveRecap(recap) + require.NoError(t, err) + + // Mark as read + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + updatedRecap, appErr := th.App.MarkRecapAsRead(ctx, savedRecap) + require.Nil(t, appErr) + require.NotNil(t, updatedRecap) + assert.Greater(t, updatedRecap.ReadAt, int64(0)) + }) + + t.Run("mark recap as read by non-owner", func(t *testing.T) { + recap := &model.Recap{ + Id: model.NewId(), + UserId: th.BasicUser.Id, + Title: "Test Recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 10, + Status: model.RecapStatusCompleted, + } + + savedRecap, err := th.App.Srv().Store().Recap().SaveRecap(recap) + require.NoError(t, err) + + // Try to mark as read as a different user - create context with BasicUser2's session + ctx := request.TestContext(t).WithSession(&model.Session{UserId: th.BasicUser2.Id}) + updatedRecap, appErr := th.App.MarkRecapAsRead(ctx, savedRecap) + // Permissions are now checked in API layer, so App layer should allow it + require.Nil(t, appErr) + require.NotNil(t, updatedRecap) + assert.Greater(t, updatedRecap.ReadAt, int64(0)) + }) +} + +func TestProcessRecapChannel(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_ENABLEAIRECAPS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_ENABLEAIRECAPS") + + th := Setup(t).InitBasic(t) + + t.Run("process empty channel", func(t *testing.T) { + // Ensure channel has no posts (it shouldn't in init) + channel := th.CreateChannel(t, th.BasicTeam) + // No posts added + + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + recapID := model.NewId() + agentID := "test-agent" + + result, err := th.App.ProcessRecapChannel(ctx, recapID, channel.Id, th.BasicUser.Id, agentID) + require.Nil(t, err) + require.NotNil(t, result) + assert.True(t, result.Success) + assert.Equal(t, 0, result.MessageCount) + }) + + t.Run("process channel with posts", func(t *testing.T) { + // This test expects failure at SummarizePosts because we can't mock AI easily in integration test + channel := th.CreateChannel(t, th.BasicTeam) + th.CreatePost(t, channel) + + ctx := th.Context.WithSession(&model.Session{UserId: th.BasicUser.Id}) + recapID := model.NewId() + agentID := "test-agent" + + result, err := th.App.ProcessRecapChannel(ctx, recapID, channel.Id, th.BasicUser.Id, agentID) + // It will fail at SummarizePosts agent call + require.NotNil(t, err) + assert.Equal(t, "app.ai.summarize.agent_call_failed", err.Id) + assert.False(t, result.Success) + }) +} + +func TestExtractPostIDs(t *testing.T) { + t.Run("extract post IDs from posts", func(t *testing.T) { + posts := []*model.Post{ + {Id: "post1", Message: "test1"}, + {Id: "post2", Message: "test2"}, + {Id: "post3", Message: "test3"}, + } + + ids := extractPostIDs(posts) + assert.Len(t, ids, 3) + assert.Equal(t, "post1", ids[0]) + assert.Equal(t, "post2", ids[1]) + assert.Equal(t, "post3", ids[2]) + }) + + t.Run("extract from empty posts", func(t *testing.T) { + posts := []*model.Post{} + ids := extractPostIDs(posts) + assert.Len(t, ids, 0) + }) +} diff --git a/server/channels/app/report.go b/server/channels/app/report.go index aab2f71afac..b91c45df964 100644 --- a/server/channels/app/report.go +++ b/server/channels/app/report.go @@ -138,7 +138,7 @@ func (a *App) SendReportToUser(rctx request.CTX, job *model.Job, format string) FileIds: []string{fileInfo.Id}, } - _, err = a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}) + _, _, err = a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}) return err } @@ -252,7 +252,7 @@ func (a *App) StartUsersBatchExport(rctx request.CTX, ro *model.UserReportOption UserId: systemBot.UserId, } - if _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { + if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { rctx.Logger().Error("Failed to post batch export message", mlog.Err(err)) } }) diff --git a/server/channels/app/scheduled_post.go b/server/channels/app/scheduled_post.go index 6ef3cfdaad4..3e23c43780e 100644 --- a/server/channels/app/scheduled_post.go +++ b/server/channels/app/scheduled_post.go @@ -73,7 +73,6 @@ func (a *App) UpdateScheduledPost(rctx request.CTX, userId string, scheduledPost return nil, validationErr } - // validate the scheduled post belongs to the said user existingScheduledPost, err := a.Srv().Store().ScheduledPost().Get(scheduledPost.Id) if err != nil { return nil, model.NewAppError("app.UpdateScheduledPost", "app.update_scheduled_post.get_scheduled_post.error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPost.Id}, "", http.StatusInternalServerError).Wrap(err) @@ -83,10 +82,6 @@ func (a *App) UpdateScheduledPost(rctx request.CTX, userId string, scheduledPost return nil, model.NewAppError("app.UpdateScheduledPost", "app.update_scheduled_post.existing_scheduled_post.not_exist", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPost.Id}, "", http.StatusNotFound) } - if existingScheduledPost.UserId != userId { - return nil, model.NewAppError("app.UpdateScheduledPost", "app.update_scheduled_post.update_permission.error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPost.Id}, "", http.StatusForbidden) - } - // This step is not required for update but is useful as we want to return the // updated scheduled post. It's better to do this before calling update than after. scheduledPost.RestoreNonUpdatableFields(existingScheduledPost) @@ -110,10 +105,6 @@ func (a *App) DeleteScheduledPost(rctx request.CTX, userId, scheduledPostId, con return nil, model.NewAppError("app.DeleteScheduledPost", "app.delete_scheduled_post.existing_scheduled_post.not_exist", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPostId}, "", http.StatusNotFound) } - if scheduledPost.UserId != userId { - return nil, model.NewAppError("app.DeleteScheduledPost", "app.delete_scheduled_post.delete_permission.error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPostId}, "", http.StatusForbidden) - } - if err := a.Srv().Store().ScheduledPost().PermanentlyDeleteScheduledPosts([]string{scheduledPostId}); err != nil { return nil, model.NewAppError("app.DeleteScheduledPost", "app.delete_scheduled_post.delete_error", map[string]any{"user_id": userId, "scheduled_post_id": scheduledPostId}, "", http.StatusInternalServerError).Wrap(err) } diff --git a/server/channels/app/scheduled_post_job.go b/server/channels/app/scheduled_post_job.go index aba737775d5..f1316b52cfa 100644 --- a/server/channels/app/scheduled_post_job.go +++ b/server/channels/app/scheduled_post_job.go @@ -190,7 +190,7 @@ func (a *App) postScheduledPost(rctx request.CTX, scheduledPost *model.Scheduled return scheduledPost, err } - _, appErr = a.CreatePost(rctx.WithContext(context.WithValue(rctx.Context(), model.PostContextKeyIsScheduledPost, true)), post, channel, model.CreatePostFlags{ + _, _, appErr = a.CreatePost(rctx.WithContext(context.WithValue(rctx.Context(), model.PostContextKeyIsScheduledPost, true)), post, channel, model.CreatePostFlags{ TriggerWebhooks: true, SetOnline: false, }) @@ -452,7 +452,7 @@ func (a *App) notifyUser(rctx request.CTX, userId string, userFailedMessages []* UserId: systemBot.UserId, } - if _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { + if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { rctx.Logger().Error("Failed to post notification about failed scheduled messages", mlog.Err(err)) } } diff --git a/server/channels/app/scheduled_post_test.go b/server/channels/app/scheduled_post_test.go index e4c9e457c96..8665d810886 100644 --- a/server/channels/app/scheduled_post_test.go +++ b/server/channels/app/scheduled_post_test.go @@ -567,54 +567,6 @@ func TestUpdateScheduledPost(t *testing.T) { require.Equal(t, "Updated Message!!!", updatedScheduledPost.Message) }) - t.Run("should ot be allowed to updated a scheduled post not belonging to the user", func(t *testing.T) { - // first we'll create a scheduled post - userId := model.NewId() - - channel, err := th.GetSqlStore().Channel().Save(th.Context, &model.Channel{ - Name: model.NewId(), - DisplayName: "Channel", - Type: model.ChannelTypeOpen, - }, 1000) - require.NoError(t, err) - - _, err = th.GetSqlStore().Channel().SaveMember(th.Context, &model.ChannelMember{ - ChannelId: channel.Id, - UserId: userId, - NotifyProps: model.GetDefaultChannelNotifyProps(), - SchemeGuest: false, - SchemeUser: true, - }) - require.NoError(t, err) - - defer func() { - _ = th.GetSqlStore().Channel().Delete(channel.Id, model.GetMillis()) - _ = th.GetSqlStore().Channel().RemoveMember(th.Context, channel.Id, userId) - }() - - scheduledPost := &model.ScheduledPost{ - Draft: model.Draft{ - CreateAt: model.GetMillis(), - UserId: userId, - ChannelId: channel.Id, - Message: "this is a scheduled post", - }, - ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future - } - createdScheduledPost, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost, user1ConnID) - require.Nil(t, appErr) - require.NotNil(t, createdScheduledPost) - - // now we'll try updating it - newScheduledAtTime := model.GetMillis() + 9999999 - createdScheduledPost.ScheduledAt = newScheduledAtTime - createdScheduledPost.Message = "Updated Message!!!" - updatedScheduledPost, appErr := th.App.UpdateScheduledPost(th.Context, th.BasicUser2.Id, createdScheduledPost, user1ConnID) - require.NotNil(t, appErr) - require.Equal(t, http.StatusForbidden, appErr.StatusCode) - require.Nil(t, updatedScheduledPost) - }) - t.Run("should only allow updating limited fields", func(t *testing.T) { // first we'll create a scheduled post userId := model.NewId() @@ -853,41 +805,6 @@ func TestDeleteScheduledPost(t *testing.T) { require.Nil(t, reFetchedScheduledPost) }) - t.Run("should not allow deleting someone else's scheduled post", func(t *testing.T) { - // first we'll create a scheduled post - scheduledPost := &model.ScheduledPost{ - Draft: model.Draft{ - CreateAt: model.GetMillis(), - UserId: th.BasicUser.Id, - ChannelId: th.BasicChannel.Id, - Message: "this is a scheduled post", - }, - ScheduledAt: model.GetMillis() + 100000, // 100 seconds in the future - } - createdScheduledPost, appErr := th.App.SaveScheduledPost(th.Context, scheduledPost, user1ConnID) - require.Nil(t, appErr) - require.NotNil(t, createdScheduledPost) - - fetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(scheduledPost.Id) - require.NoError(t, err) - require.NotNil(t, fetchedScheduledPost) - require.Equal(t, createdScheduledPost.Id, fetchedScheduledPost.Id) - require.Equal(t, createdScheduledPost.Message, fetchedScheduledPost.Message) - - // now we'll delete it - var deletedScheduledPost *model.ScheduledPost - deletedScheduledPost, appErr = th.App.DeleteScheduledPost(th.Context, th.BasicUser2.Id, scheduledPost.Id, "connection_id") - require.NotNil(t, appErr) - require.Nil(t, deletedScheduledPost) - - // try to fetch it again - reFetchedScheduledPost, err := th.Server.Store().ScheduledPost().Get(scheduledPost.Id) - require.NoError(t, err) - require.NotNil(t, reFetchedScheduledPost) - require.Equal(t, createdScheduledPost.Id, reFetchedScheduledPost.Id) - require.Equal(t, createdScheduledPost.Message, reFetchedScheduledPost.Message) - }) - t.Run("should producer error when deleting non existing scheduled post", func(t *testing.T) { var deletedScheduledPost *model.ScheduledPost deletedScheduledPost, appErr := th.App.DeleteScheduledPost(th.Context, th.BasicUser.Id, model.NewId(), "connection_id") diff --git a/server/channels/app/server.go b/server/channels/app/server.go index 884e890032d..109822c4aa3 100644 --- a/server/channels/app/server.go +++ b/server/channels/app/server.go @@ -61,6 +61,7 @@ import ( "github.com/mattermost/mattermost/server/v8/channels/jobs/plugins" "github.com/mattermost/mattermost/server/v8/channels/jobs/post_persistent_notifications" "github.com/mattermost/mattermost/server/v8/channels/jobs/product_notices" + "github.com/mattermost/mattermost/server/v8/channels/jobs/recap" "github.com/mattermost/mattermost/server/v8/channels/jobs/refresh_materialized_views" "github.com/mattermost/mattermost/server/v8/channels/jobs/resend_invitation_email" "github.com/mattermost/mattermost/server/v8/channels/jobs/s3_path_migration" @@ -383,7 +384,10 @@ func NewServer(options ...Option) (*Server, error) { } if _, err = url.ParseRequestURI(*s.platform.Config().ServiceSettings.SiteURL); err != nil { - mlog.Error("SiteURL must be set. Some features will operate incorrectly if the SiteURL is not set. See documentation for details: https://mattermost.com/pl/configure-site-url") + // Don't spam the logs when in CI or local testing mode + if !(os.Getenv("IS_CI") == "true" || os.Getenv("IS_LOCAL_TESTING") == "true") { + mlog.Error("SiteURL must be set. Some features will operate incorrectly if the SiteURL is not set. See documentation for details: https://mattermost.com/pl/configure-site-url") + } } // Start email batching because it's not like the other jobs @@ -1624,6 +1628,12 @@ func (s *Server) initJobs() { delete_dms_preferences_migration.MakeWorker(s.Jobs, s.Store(), New(ServerConnector(s.Channels()))), nil) + s.Jobs.RegisterJobType( + model.JobTypeRecap, + recap.MakeWorker(s.Jobs, s.Store(), New(ServerConnector(s.Channels()))), + nil, + ) + s.Jobs.RegisterJobType( model.JobTypeDeleteExpiredPosts, delete_expired_posts.MakeWorker(s.Jobs, s.Store(), New(ServerConnector(s.Channels()))), diff --git a/server/channels/app/shared_channel_test.go b/server/channels/app/shared_channel_test.go index ce8c552c27c..40a482b83ee 100644 --- a/server/channels/app/shared_channel_test.go +++ b/server/channels/app/shared_channel_test.go @@ -365,7 +365,7 @@ func TestApp_RemoteUnsharing(t *testing.T) { UserId: th.BasicUser.Id, Message: "Test message after remote 1 unshare", } - _, appErr := th.App.CreatePost(th.Context, post, channel, model.CreatePostFlags{}) + _, _, appErr := th.App.CreatePost(th.Context, post, channel, model.CreatePostFlags{}) require.Nil(t, appErr) // Get post count after creating the test post but before "remote-initiated unshare" diff --git a/server/channels/app/slashcommands/auto_posts.go b/server/channels/app/slashcommands/auto_posts.go index 4030b6bb428..5230ae9d208 100644 --- a/server/channels/app/slashcommands/auto_posts.go +++ b/server/channels/app/slashcommands/auto_posts.go @@ -114,7 +114,7 @@ func (cfg *AutoPostCreator) CreateRandomPostNested(rctx request.CTX, rootID stri post.UserId = cfg.UsersToPostFrom[i] } } - rpost, err := cfg.a.CreatePostMissingChannel(rctx, post, true, true) + rpost, _, err := cfg.a.CreatePostMissingChannel(rctx, post, true, true) if err != nil { return nil, err } diff --git a/server/channels/app/slashcommands/command_channel_header.go b/server/channels/app/slashcommands/command_channel_header.go index f59ba4b5975..a3733670606 100644 --- a/server/channels/app/slashcommands/command_channel_header.go +++ b/server/channels/app/slashcommands/command_channel_header.go @@ -46,7 +46,7 @@ func (*HeaderProvider) DoCommand(a *app.App, rctx request.CTX, args *model.Comma switch channel.Type { case model.ChannelTypeOpen: - if !a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePublicChannelProperties) { + if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePublicChannelProperties); !ok { return &model.CommandResponse{ Text: args.T("api.command_channel_header.permission.app_error"), ResponseType: model.CommandResponseTypeEphemeral, @@ -54,7 +54,7 @@ func (*HeaderProvider) DoCommand(a *app.App, rctx request.CTX, args *model.Comma } case model.ChannelTypePrivate: - if !a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePrivateChannelProperties) { + if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePrivateChannelProperties); !ok { return &model.CommandResponse{ Text: args.T("api.command_channel_header.permission.app_error"), ResponseType: model.CommandResponseTypeEphemeral, diff --git a/server/channels/app/slashcommands/command_channel_purpose.go b/server/channels/app/slashcommands/command_channel_purpose.go index acd629857c9..fbc6ee21b74 100644 --- a/server/channels/app/slashcommands/command_channel_purpose.go +++ b/server/channels/app/slashcommands/command_channel_purpose.go @@ -46,14 +46,14 @@ func (*PurposeProvider) DoCommand(a *app.App, rctx request.CTX, args *model.Comm switch channel.Type { case model.ChannelTypeOpen: - if !a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePublicChannelProperties) { + if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePublicChannelProperties); !ok { return &model.CommandResponse{ Text: args.T("api.command_channel_purpose.permission.app_error"), ResponseType: model.CommandResponseTypeEphemeral, } } case model.ChannelTypePrivate: - if !a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePrivateChannelProperties) { + if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePrivateChannelProperties); !ok { return &model.CommandResponse{ Text: args.T("api.command_channel_purpose.permission.app_error"), ResponseType: model.CommandResponseTypeEphemeral, diff --git a/server/channels/app/slashcommands/command_channel_rename.go b/server/channels/app/slashcommands/command_channel_rename.go index ddb0e6f6797..4ee333df610 100644 --- a/server/channels/app/slashcommands/command_channel_rename.go +++ b/server/channels/app/slashcommands/command_channel_rename.go @@ -49,14 +49,14 @@ func (*RenameProvider) DoCommand(a *app.App, rctx request.CTX, args *model.Comma switch channel.Type { case model.ChannelTypeOpen: - if !a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePublicChannelProperties) { + if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePublicChannelProperties); !ok { return &model.CommandResponse{ Text: args.T("api.command_channel_rename.permission.app_error"), ResponseType: model.CommandResponseTypeEphemeral, } } case model.ChannelTypePrivate: - if !a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePrivateChannelProperties) { + if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePrivateChannelProperties); !ok { return &model.CommandResponse{ Text: args.T("api.command_channel_rename.permission.app_error"), ResponseType: model.CommandResponseTypeEphemeral, diff --git a/server/channels/app/slashcommands/command_echo.go b/server/channels/app/slashcommands/command_echo.go index 7276615c6ea..bba5d961a25 100644 --- a/server/channels/app/slashcommands/command_echo.go +++ b/server/channels/app/slashcommands/command_echo.go @@ -89,7 +89,7 @@ func (*EchoProvider) DoCommand(a *app.App, rctx request.CTX, args *model.Command time.Sleep(time.Duration(delay) * time.Second) - if _, err := a.CreatePostMissingChannel(rctx, post, true, true); err != nil { + if _, _, err := a.CreatePostMissingChannel(rctx, post, true, true); err != nil { rctx.Logger().Error("Unable to create /echo post.", mlog.Err(err)) } }) diff --git a/server/channels/app/slashcommands/command_groupmsg.go b/server/channels/app/slashcommands/command_groupmsg.go index e589d36efd7..ddfe207a3b8 100644 --- a/server/channels/app/slashcommands/command_groupmsg.go +++ b/server/channels/app/slashcommands/command_groupmsg.go @@ -126,7 +126,7 @@ func (*groupmsgProvider) DoCommand(a *app.App, rctx request.CTX, args *model.Com post.Message = parsedMessage post.ChannelId = groupChannel.Id post.UserId = args.UserId - if _, err := a.CreatePostMissingChannel(rctx, post, true, true); err != nil { + if _, _, err := a.CreatePostMissingChannel(rctx, post, true, true); err != nil { return &model.CommandResponse{Text: args.T("api.command_groupmsg.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral} } } diff --git a/server/channels/app/slashcommands/command_invite.go b/server/channels/app/slashcommands/command_invite.go index 4b12f4598b4..645f8b96ac4 100644 --- a/server/channels/app/slashcommands/command_invite.go +++ b/server/channels/app/slashcommands/command_invite.go @@ -266,7 +266,7 @@ func (i *InviteProvider) checkPermissions(a *app.App, rctx request.CTX, args *mo for _, targetChannel := range targetChannels { switch targetChannel.Type { case model.ChannelTypeOpen: - if !a.HasPermissionToChannel(rctx, args.UserId, targetChannel.Id, model.PermissionManagePublicChannelMembers) { + if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, targetChannel.Id, model.PermissionManagePublicChannelMembers); !ok { *resps = append(*resps, args.T("api.command_invite.permission.app_error", map[string]any{ "User": targetUser.Username, "Channel": targetChannel.Name, @@ -274,7 +274,7 @@ func (i *InviteProvider) checkPermissions(a *app.App, rctx request.CTX, args *mo continue } case model.ChannelTypePrivate: - if !a.HasPermissionToChannel(rctx, args.UserId, targetChannel.Id, model.PermissionManagePrivateChannelMembers) { + if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, targetChannel.Id, model.PermissionManagePrivateChannelMembers); !ok { if _, err = a.GetChannelMember(rctx, targetChannel.Id, args.UserId); err == nil { // User doing the inviting is a member of the channel. *resps = append(*resps, args.T("api.command_invite.permission.app_error", map[string]any{ diff --git a/server/channels/app/slashcommands/command_join.go b/server/channels/app/slashcommands/command_join.go index b3a14b2a716..79c5a0b44df 100644 --- a/server/channels/app/slashcommands/command_join.go +++ b/server/channels/app/slashcommands/command_join.go @@ -55,11 +55,11 @@ func (*JoinProvider) DoCommand(a *app.App, rctx request.CTX, args *model.Command switch channel.Type { case model.ChannelTypeOpen: - if !a.HasPermissionToChannel(rctx, args.UserId, channel.Id, model.PermissionJoinPublicChannels) { + if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, channel.Id, model.PermissionJoinPublicChannels); !ok { return &model.CommandResponse{Text: args.T("api.command_join.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral} } case model.ChannelTypePrivate: - if !a.HasPermissionToChannel(rctx, args.UserId, channel.Id, model.PermissionReadChannel) { + if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, channel.Id, model.PermissionReadChannel); !ok { return &model.CommandResponse{Text: args.T("api.command_join.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral} } default: diff --git a/server/channels/app/slashcommands/command_loadtest.go b/server/channels/app/slashcommands/command_loadtest.go index ae87ea971a1..bddaab3aa5a 100644 --- a/server/channels/app/slashcommands/command_loadtest.go +++ b/server/channels/app/slashcommands/command_loadtest.go @@ -673,7 +673,7 @@ func (*LoadTestProvider) URLCommand(a *app.App, rctx request.CTX, args *model.Co post.ChannelId = args.ChannelId post.UserId = args.UserId - if _, err := a.CreatePostMissingChannel(rctx, post, false, true); err != nil { + if _, _, err := a.CreatePostMissingChannel(rctx, post, false, true); err != nil { return &model.CommandResponse{Text: "Unable to create post", ResponseType: model.CommandResponseTypeEphemeral}, err } } @@ -722,7 +722,7 @@ func (*LoadTestProvider) JSONCommand(a *app.App, rctx request.CTX, args *model.C post.Message = message } - if _, err := a.CreatePostMissingChannel(rctx, &post, false, true); err != nil { + if _, _, err := a.CreatePostMissingChannel(rctx, &post, false, true); err != nil { return &model.CommandResponse{Text: "Unable to create post", ResponseType: model.CommandResponseTypeEphemeral}, err } diff --git a/server/channels/app/slashcommands/command_msg.go b/server/channels/app/slashcommands/command_msg.go index c6b80828f4f..0739cbbad4b 100644 --- a/server/channels/app/slashcommands/command_msg.go +++ b/server/channels/app/slashcommands/command_msg.go @@ -100,7 +100,7 @@ func (*msgProvider) DoCommand(a *app.App, rctx request.CTX, args *model.CommandA post.Message = parsedMessage post.ChannelId = targetChannelID post.UserId = args.UserId - if _, err = a.CreatePostMissingChannel(rctx, post, true, true); err != nil { + if _, _, err = a.CreatePostMissingChannel(rctx, post, true, true); err != nil { return &model.CommandResponse{Text: args.T("api.command_msg.fail.app_error"), ResponseType: model.CommandResponseTypeEphemeral} } } diff --git a/server/channels/app/slashcommands/command_remove.go b/server/channels/app/slashcommands/command_remove.go index a22433aed49..4f4443ccf9b 100644 --- a/server/channels/app/slashcommands/command_remove.go +++ b/server/channels/app/slashcommands/command_remove.go @@ -75,14 +75,14 @@ func doCommand(a *app.App, rctx request.CTX, args *model.CommandArgs, message st switch channel.Type { case model.ChannelTypeOpen: - if !a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePublicChannelMembers) { + if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePublicChannelMembers); !ok { return &model.CommandResponse{ Text: args.T("api.command_remove.permission.app_error"), ResponseType: model.CommandResponseTypeEphemeral, } } case model.ChannelTypePrivate: - if !a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePrivateChannelMembers) { + if ok, _ := a.HasPermissionToChannel(rctx, args.UserId, args.ChannelId, model.PermissionManagePrivateChannelMembers); !ok { return &model.CommandResponse{ Text: args.T("api.command_remove.permission.app_error"), ResponseType: model.CommandResponseTypeEphemeral, diff --git a/server/channels/app/slashcommands/helper_test.go b/server/channels/app/slashcommands/helper_test.go index 0359d4aac91..d386b9a3a0c 100644 --- a/server/channels/app/slashcommands/helper_test.go +++ b/server/channels/app/slashcommands/helper_test.go @@ -353,7 +353,7 @@ func (th *TestHelper) createPost(tb testing.TB, channel *model.Channel) *model.P CreateAt: model.GetMillis() - 10000, } - post, appErr := th.App.CreatePost(th.Context, post, channel, model.CreatePostFlags{SetOnline: true}) + post, _, appErr := th.App.CreatePost(th.Context, post, channel, model.CreatePostFlags{SetOnline: true}) require.Nil(tb, appErr) return post } diff --git a/server/channels/app/summarization.go b/server/channels/app/summarization.go new file mode 100644 index 00000000000..f25cd6a7eb1 --- /dev/null +++ b/server/channels/app/summarization.go @@ -0,0 +1,130 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + agentclient "github.com/mattermost/mattermost-plugin-ai/public/bridgeclient" + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/public/shared/request" +) + +// SummarizePosts generates an AI summary of posts with highlights and action items +func (a *App) SummarizePosts(rctx request.CTX, userID string, posts []*model.Post, channelName, teamName string, agentID string) (*model.AIRecapSummaryResponse, *model.AppError) { + if len(posts) == 0 { + return &model.AIRecapSummaryResponse{Highlights: []string{}, ActionItems: []string{}}, nil + } + + // Get site URL for permalink generation + siteURL := a.GetSiteURL() + + // Build conversation context from posts and collect post IDs + conversationText, postIDs := buildConversationTextWithIDs(posts) + + systemPrompt := "You are an expert at analyzing team conversations and extracting key information. Your task is to summarize a conversation from a Mattermost channel, identifying the most important highlights and any actionable items. Return ONLY valid JSON with 'highlights' and 'action_items' keys, each containing an array of strings. If there are no highlights or action items, return empty arrays. Do not make up information - only include items explicitly mentioned in the conversation." + + userPrompt := fmt.Sprintf(`Analyze the following conversation from the "%s" channel and provide a summary. + +Site URL: %s +Team Name: %s + +Conversation: +%s + +Available Post IDs: %s + +Return a JSON object with: +- "highlights": array of key discussion points, decisions, or important information +- "action_items": array of tasks, todos, or action items mentioned + +IMPORTANT INSTRUCTIONS: +1. When your summary includes a user's username, prepend an @ symbol to the username. For example if you return a highlight with text ' sent an update about project xyz', where is 'john.smith', you should phrase is as '@john.smith sent an update about project xyz'. + +2. For EACH highlight and action item, you MUST append a permalink to cite the source. The permalink should reference the most relevant post from the conversation. Format the permalink at the END of each item as: [PERMALINK:%s/%s/pl/] where is one of the available post IDs provided above. Choose the post ID that is most relevant to that specific highlight or action item. + +Example format: "Team decided to migrate to microservices architecture [PERMALINK:%s/%s/pl/abc123xyz]" + +Your response must be compacted valid JSON only, with no additional text, formatting, nor code blocks.`, channelName, siteURL, teamName, conversationText, strings.Join(postIDs, ", "), siteURL, teamName, siteURL, teamName) + + // Create bridge client + sessionUserID := "" + if session := rctx.Session(); session != nil { + sessionUserID = session.UserId + } + client := a.getBridgeClient(sessionUserID) + + completionRequest := agentclient.CompletionRequest{ + Posts: []agentclient.Post{ + {Role: "system", Message: systemPrompt}, + {Role: "user", Message: userPrompt}, + }, + } + + rctx.Logger().Debug("Calling AI agent for post summarization", + mlog.String("channel_name", channelName), + mlog.String("user_id", userID), + mlog.String("agent_id", agentID), + mlog.Int("post_count", len(posts)), + ) + + completion, err := client.AgentCompletion(agentID, completionRequest) + if err != nil { + return nil, model.NewAppError("SummarizePosts", "app.ai.summarize.agent_call_failed", nil, err.Error(), http.StatusInternalServerError) + } + + var summary model.AIRecapSummaryResponse + if err := json.Unmarshal([]byte(completion), &summary); err != nil { + return nil, model.NewAppError("SummarizePosts", "app.ai.summarize.parse_failed", nil, err.Error(), http.StatusInternalServerError) + } + + // Ensure arrays are never nil + if summary.Highlights == nil { + summary.Highlights = []string{} + } + if summary.ActionItems == nil { + summary.ActionItems = []string{} + } + + rctx.Logger().Debug("AI summarization successful", + mlog.String("channel_name", channelName), + mlog.Int("highlights_count", len(summary.Highlights)), + mlog.Int("action_items_count", len(summary.ActionItems)), + ) + + return &summary, nil +} + +func buildConversationTextWithIDs(posts []*model.Post) (string, []string) { + var sb strings.Builder + postIDs := make([]string, 0, len(posts)) + + for _, post := range posts { + // Collect post ID + postIDs = append(postIDs, post.Id) + + // Posts should have Username populated by the caller + // For posts without username, use UserId as fallback + username := "" + if usernameProp := post.GetProp("username"); usernameProp != nil { + if usernameStr, ok := usernameProp.(string); ok { + username = usernameStr + } + } + if username == "" { + username = post.UserId + } + sb.WriteString(fmt.Sprintf("[%s] %s (Post ID: %s): %s\n", + time.UnixMilli(post.CreateAt).Format("15:04"), + username, + post.Id, + post.Message)) + } + return sb.String(), postIDs +} diff --git a/server/channels/app/summarization_test.go b/server/channels/app/summarization_test.go new file mode 100644 index 00000000000..3905066c09a --- /dev/null +++ b/server/channels/app/summarization_test.go @@ -0,0 +1,65 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/assert" +) + +func TestBuildConversationText(t *testing.T) { + t.Run("build conversation with posts", func(t *testing.T) { + posts := []*model.Post{ + { + Id: model.NewId(), + Message: "Hello world", + UserId: "user1", + CreateAt: 1234567890000, + Props: model.StringInterface{ + "username": "john_doe", + }, + }, + { + Id: model.NewId(), + Message: "How are you?", + UserId: "user2", + CreateAt: 1234567895000, + Props: model.StringInterface{ + "username": "jane_smith", + }, + }, + } + + result, _ := buildConversationTextWithIDs(posts) + assert.Contains(t, result, "john_doe") + assert.Contains(t, result, "jane_smith") + assert.Contains(t, result, "Hello world") + assert.Contains(t, result, "How are you?") + }) + + t.Run("build conversation with posts without username", func(t *testing.T) { + posts := []*model.Post{ + { + Id: model.NewId(), + Message: "Test message", + UserId: "user123", + CreateAt: 1234567890000, + Props: model.StringInterface{}, + }, + } + + result, _ := buildConversationTextWithIDs(posts) + // Should fallback to user ID when no username prop + assert.Contains(t, result, "user123") + assert.Contains(t, result, "Test message") + }) + + t.Run("build conversation with empty posts", func(t *testing.T) { + posts := []*model.Post{} + result, _ := buildConversationTextWithIDs(posts) + assert.Equal(t, "", result) + }) +} diff --git a/server/channels/app/team.go b/server/channels/app/team.go index 7a046f870d5..2fd5e6562fd 100644 --- a/server/channels/app/team.go +++ b/server/channels/app/team.go @@ -393,7 +393,22 @@ func (a *App) GetSchemeRolesForTeam(teamID string) (string, string, string, *mod return model.TeamGuestRoleId, model.TeamUserRoleId, model.TeamAdminRoleId, nil } +// UpdateTeamMemberRoles updates the roles for a team member. +// This is the public API used by REST endpoints and plugins. +// It enforces strict validation requiring either SchemeUser or SchemeGuest to be true. func (a *App) UpdateTeamMemberRoles(rctx request.CTX, teamID string, userID string, newRoles string) (*model.TeamMember, *model.AppError) { + return a.updateTeamMemberRolesInternal(rctx, teamID, userID, newRoles, false) +} + +// updateTeamMemberRolesInternal is the internal implementation of UpdateTeamMemberRoles. +// The allowSchemeUserUnset parameter controls whether to enforce the requirement that members +// must have either SchemeUser or SchemeGuest set to true. +// +// When allowSchemeUserUnset is false (default for API/plugin calls), the function enforces +// that members must have a base scheme role. When true (bulk import only), this validation +// is skipped to support the two-phase import pattern where explicit roles are set first, +// then scheme roles are set via UpdateTeamMemberSchemeRoles immediately after. +func (a *App) updateTeamMemberRolesInternal(rctx request.CTX, teamID string, userID string, newRoles string, allowSchemeUserUnset bool) (*model.TeamMember, *model.AppError) { member, nErr := a.Srv().Store().Team().GetMember(rctx, teamID, userID) if nErr != nil { var nfErr *store.ErrNotFound @@ -455,6 +470,13 @@ func (a *App) UpdateTeamMemberRoles(rctx request.CTX, teamID string, userID stri return nil, model.NewAppError("UpdateTeamMemberRoles", "api.channel.update_team_member_roles.changing_guest_role.app_error", nil, "", http.StatusBadRequest) } + // Validate that the member has a base scheme role (SchemeUser or SchemeGuest). + // This ensures members always have the minimum required permissions. + // Bulk import operations may skip this validation temporarily. + if !allowSchemeUserUnset && !member.SchemeGuest && !member.SchemeUser { + return nil, model.NewAppError("UpdateTeamMemberRoles", "api.team.update_team_member_roles.unset_user_scheme.app_error", nil, "", http.StatusBadRequest) + } + member.ExplicitRoles = strings.Join(newExplicitRoles, " ") member, nErr = a.Srv().Store().Team().UpdateMember(rctx, member) @@ -1291,7 +1313,7 @@ func (a *App) postLeaveTeamMessage(rctx request.CTX, user *model.User, channel * }, } - if _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { + if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { return model.NewAppError("postRemoveFromChannelMessage", "api.channel.post_user_add_remove_message_and_forget.error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -1309,7 +1331,7 @@ func (a *App) postRemoveFromTeamMessage(rctx request.CTX, user *model.User, chan }, } - if _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { + if _, _, err := a.CreatePost(rctx, post, channel, model.CreatePostFlags{SetOnline: true}); err != nil { return model.NewAppError("postRemoveFromTeamMessage", "api.channel.post_user_add_remove_message_and_forget.error", nil, "", http.StatusInternalServerError).Wrap(err) } diff --git a/server/channels/app/team_test.go b/server/channels/app/team_test.go index 5ebc96b1eb5..3a9f826ebdb 100644 --- a/server/channels/app/team_test.go +++ b/server/channels/app/team_test.go @@ -1265,12 +1265,14 @@ func TestAppUpdateTeamScheme(t *testing.T) { }, } // ensure user can update channel properties before applying the scheme - require.True(t, th.App.SessionHasPermissionToChannel(th.Context, session, channel.Id, model.PermissionManagePublicChannelProperties)) + ok, _ := th.App.SessionHasPermissionToChannel(th.Context, session, channel.Id, model.PermissionManagePublicChannelProperties) + require.True(t, ok) // apply the team scheme team2.SchemeId = &team2Scheme.Id _, appErr = th.App.UpdateTeamScheme(team2) require.Nil(t, appErr) - require.False(t, th.App.SessionHasPermissionToChannel(th.Context, session, channel.Id, model.PermissionManagePublicChannelProperties)) + ok, _ = th.App.SessionHasPermissionToChannel(th.Context, session, channel.Id, model.PermissionManagePublicChannelProperties) + require.False(t, ok) } func TestGetTeamMembers(t *testing.T) { @@ -1511,6 +1513,184 @@ func TestUpdateTeamMemberRolesChangingGuest(t *testing.T) { }) } +func TestUpdateTeamMemberRolesRequireUser(t *testing.T) { + mainHelper.Parallel(t) + th := Setup(t).InitBasic(t) + + t.Run("empty roles string requires user or guest scheme role", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Tester", Username: "tester" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser, _ := th.App.CreateUser(th.Context, &user) + + _, _, err := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, ruser.Id, "") + require.Nil(t, err) + + member, err := th.App.GetTeamMember(th.Context, th.BasicTeam.Id, ruser.Id) + require.Nil(t, err) + require.True(t, member.SchemeUser) + require.False(t, member.SchemeGuest) + + _, err = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, ruser.Id, "") + require.NotNil(t, err) + require.Equal(t, "api.team.update_team_member_roles.unset_user_scheme.app_error", err.Id) + }) + + t.Run("admin role requires user or guest scheme role", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Tester", Username: "tester" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser, _ := th.App.CreateUser(th.Context, &user) + + _, _, err := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, ruser.Id, "") + require.Nil(t, err) + + _, err = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, ruser.Id, "team_admin") + require.NotNil(t, err) + require.Equal(t, "api.team.update_team_member_roles.unset_user_scheme.app_error", err.Id) + }) + + t.Run("valid user and admin roles update succeeds", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Tester", Username: "tester" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser, _ := th.App.CreateUser(th.Context, &user) + + _, _, err := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, ruser.Id, "") + require.Nil(t, err) + + updatedMember, err := th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, ruser.Id, "team_user team_admin") + require.Nil(t, err) + require.True(t, updatedMember.SchemeUser) + require.True(t, updatedMember.SchemeAdmin) + require.False(t, updatedMember.SchemeGuest) + }) + + t.Run("removing admin role while keeping user role succeeds", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Tester", Username: "tester" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser, _ := th.App.CreateUser(th.Context, &user) + + _, _, err := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, ruser.Id, "") + require.Nil(t, err) + + _, err = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, ruser.Id, "team_user team_admin") + require.Nil(t, err) + + updatedMember, err := th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, ruser.Id, "team_user") + require.Nil(t, err) + require.True(t, updatedMember.SchemeUser) + require.False(t, updatedMember.SchemeAdmin) + }) + + t.Run("team_post_all alone should fail", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Tester", Username: "tester" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser, _ := th.App.CreateUser(th.Context, &user) + + _, _, err := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, ruser.Id, "") + require.Nil(t, err) + + _, err = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, ruser.Id, "team_post_all") + require.NotNil(t, err) + require.Equal(t, "api.team.update_team_member_roles.unset_user_scheme.app_error", err.Id) + }) + + t.Run("team_post_all_public alone should fail", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Tester", Username: "tester" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser, _ := th.App.CreateUser(th.Context, &user) + + _, _, err := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, ruser.Id, "") + require.Nil(t, err) + + _, err = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, ruser.Id, "team_post_all_public") + require.NotNil(t, err) + require.Equal(t, "api.team.update_team_member_roles.unset_user_scheme.app_error", err.Id) + }) + + t.Run("system_post_all alone should fail", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Tester", Username: "tester" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser, _ := th.App.CreateUser(th.Context, &user) + + _, _, err := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, ruser.Id, "") + require.Nil(t, err) + + _, err = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, ruser.Id, "system_post_all") + require.NotNil(t, err) + require.Equal(t, "api.team.update_team_member_roles.unset_user_scheme.app_error", err.Id) + }) + + t.Run("system_user_manager alone should fail", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Tester", Username: "tester" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser, _ := th.App.CreateUser(th.Context, &user) + + _, _, err := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, ruser.Id, "") + require.Nil(t, err) + + _, err = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, ruser.Id, "system_user_manager") + require.NotNil(t, err) + require.Equal(t, "api.team.update_team_member_roles.unset_user_scheme.app_error", err.Id) + }) + + t.Run("multiple non-scheme-managed roles without user scheme should fail", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Tester", Username: "tester" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser, _ := th.App.CreateUser(th.Context, &user) + + _, _, err := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, ruser.Id, "") + require.Nil(t, err) + + _, err = th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, ruser.Id, "team_post_all team_post_all_public system_post_all") + require.NotNil(t, err) + require.Equal(t, "api.team.update_team_member_roles.unset_user_scheme.app_error", err.Id) + }) + + t.Run("team_post_all with team_user should succeed", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Tester", Username: "tester" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser, _ := th.App.CreateUser(th.Context, &user) + + _, _, err := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, ruser.Id, "") + require.Nil(t, err) + + updatedMember, err := th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, ruser.Id, "team_user team_post_all") + require.Nil(t, err) + require.True(t, updatedMember.SchemeUser) + require.Contains(t, updatedMember.ExplicitRoles, "team_post_all") + }) + + t.Run("team_post_all_public with team_user should succeed", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Tester", Username: "tester" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser, _ := th.App.CreateUser(th.Context, &user) + + _, _, err := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, ruser.Id, "") + require.Nil(t, err) + + updatedMember, err := th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, ruser.Id, "team_user team_post_all_public") + require.Nil(t, err) + require.True(t, updatedMember.SchemeUser) + require.Contains(t, updatedMember.ExplicitRoles, "team_post_all_public") + }) + + t.Run("multiple explicit roles with team_user should succeed", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Tester", Username: "tester" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser, _ := th.App.CreateUser(th.Context, &user) + + _, _, err := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, ruser.Id, "") + require.Nil(t, err) + + updatedMember, err := th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, ruser.Id, "team_user team_post_all team_post_all_public") + require.Nil(t, err) + require.True(t, updatedMember.SchemeUser) + require.Contains(t, updatedMember.ExplicitRoles, "team_post_all") + require.Contains(t, updatedMember.ExplicitRoles, "team_post_all_public") + }) + + t.Run("explicit role with admin should succeed", func(t *testing.T) { + user := model.User{Email: strings.ToLower(model.NewId()) + "success+test@example.com", Nickname: "Tester", Username: "tester" + model.NewId(), Password: "passwd1", AuthService: ""} + ruser, _ := th.App.CreateUser(th.Context, &user) + + _, _, err := th.App.AddUserToTeam(th.Context, th.BasicTeam.Id, ruser.Id, "") + require.Nil(t, err) + + updatedMember, err := th.App.UpdateTeamMemberRoles(th.Context, th.BasicTeam.Id, ruser.Id, "team_user team_admin team_post_all") + require.Nil(t, err) + require.True(t, updatedMember.SchemeUser) + require.True(t, updatedMember.SchemeAdmin) + require.Contains(t, updatedMember.ExplicitRoles, "team_post_all") + }) +} + func TestInvalidateAllResendInviteEmailJobs(t *testing.T) { mainHelper.Parallel(t) th := Setup(t) diff --git a/server/channels/app/user.go b/server/channels/app/user.go index bdf09828b13..3fc77da2c9b 100644 --- a/server/channels/app/user.go +++ b/server/channels/app/user.go @@ -3036,7 +3036,7 @@ func (a *App) UpdateThreadFollowForUserFromChannelAdd(rctx request.CTX, userID, } a.sanitizeProfiles(userThread.Participants, false) userThread.Post.SanitizeProps() - sanitizedPost, appErr := a.SanitizePostMetadataForUser(rctx, userThread.Post, userID) + sanitizedPost, isMemberForPreviews, appErr := a.SanitizePostMetadataForUser(rctx, userThread.Post, userID) if appErr != nil { return appErr } @@ -3050,6 +3050,16 @@ func (a *App) UpdateThreadFollowForUserFromChannelAdd(rctx request.CTX, userID, message.Add("previous_unread_replies", int64(0)) message.Add("previous_unread_mentions", int64(0)) + auditRec := a.MakeAuditRecord(rctx, model.AuditEventWebsocketPost, model.AuditStatusSuccess) + defer a.LogAuditRec(rctx, auditRec, nil) + model.AddEventParameterToAuditRec(auditRec, "post_id", userThread.Post.Id) + model.AddEventParameterToAuditRec(auditRec, "user_id", userID) + model.AddEventParameterToAuditRec(auditRec, "source", "UpdateThreadFollowForUserFromChannelAdd") + if !isMemberForPreviews { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } + auditRec.Success() + a.Publish(message) return nil } diff --git a/server/channels/app/user_test.go b/server/channels/app/user_test.go index 7b3fe1b7440..9a6520d93eb 100644 --- a/server/channels/app/user_test.go +++ b/server/channels/app/user_test.go @@ -2287,9 +2287,9 @@ func TestUpdateThreadReadForUser(t *testing.T) { *cfg.ServiceSettings.CollapsedThreads = model.CollapsedThreadsDefaultOn }) - rootPost, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "hi"}, th.BasicChannel, model.CreatePostFlags{}) + rootPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "hi"}, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) - replyPost, appErr := th.App.CreatePost(th.Context, &model.Post{RootId: rootPost.Id, UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "hi"}, th.BasicChannel, model.CreatePostFlags{}) + replyPost, _, appErr := th.App.CreatePost(th.Context, &model.Post{RootId: rootPost.Id, UserId: th.BasicUser2.Id, CreateAt: model.GetMillis(), ChannelId: th.BasicChannel.Id, Message: "hi"}, th.BasicChannel, model.CreatePostFlags{}) require.Nil(t, appErr) threads, appErr := th.App.GetThreadsForUser(th.Context, th.BasicUser.Id, th.BasicTeam.Id, model.GetUserThreadsOpts{}) require.Nil(t, appErr) diff --git a/server/channels/app/users/main_test.go b/server/channels/app/users/main_test.go index a98408f771a..ff2890ce6b8 100644 --- a/server/channels/app/users/main_test.go +++ b/server/channels/app/users/main_test.go @@ -4,21 +4,14 @@ package users import ( - "flag" "testing" "github.com/mattermost/mattermost/server/v8/channels/testlib" ) var mainHelper *testlib.MainHelper -var replicaFlag bool func TestMain(m *testing.M) { - if f := flag.Lookup("mysql-replica"); f == nil { - flag.BoolVar(&replicaFlag, "mysql-replica", false, "") - flag.Parse() - } - var options = testlib.HelperOptions{ EnableStore: true, EnableResources: true, diff --git a/server/channels/app/web_broadcast_hooks.go b/server/channels/app/web_broadcast_hooks.go index 34ce537b592..fdf635091b0 100644 --- a/server/channels/app/web_broadcast_hooks.go +++ b/server/channels/app/web_broadcast_hooks.go @@ -165,7 +165,8 @@ func (h *permalinkBroadcastHook) Process(msg *platform.HookedWebSocketEvent, web } rctx := request.EmptyContext(webConn.Platform.Log()) - if !webConn.Suite.HasPermissionToReadChannel(rctx, webConn.UserId, previewChannel) { + ok, isMember := webConn.Suite.HasPermissionToReadChannel(rctx, webConn.UserId, previewChannel) + if !ok { // Do nothing. // In this case, the sanitized post is already attached to the ws event. return nil @@ -178,6 +179,16 @@ func (h *permalinkBroadcastHook) Process(msg *platform.HookedWebSocketEvent, web } msg.Add("post", postJSON) + auditRec := webConn.Suite.MakeAuditRecord(rctx, model.AuditEventWebsocketPost, model.AuditStatusSuccess) + defer webConn.Suite.LogAuditRec(rctx, auditRec, nil) + model.AddEventParameterToAuditRec(auditRec, "channel_id", previewChannel.Id) + model.AddEventParameterToAuditRec(auditRec, "user_id", webConn.UserId) + model.AddEventParameterToAuditRec(auditRec, "source", "permalinkBroadcastHook") + + if !isMember { + model.AddEventParameterToAuditRec(auditRec, "non_channel_member_access", true) + } + return nil } diff --git a/server/channels/app/webhook.go b/server/channels/app/webhook.go index 333c8de475a..0ad7df18e8e 100644 --- a/server/channels/app/webhook.go +++ b/server/channels/app/webhook.go @@ -371,7 +371,7 @@ func (a *App) CreateWebhookPost(rctx request.CTX, userID string, channel *model. } for _, split := range splits { - if _, err = a.CreatePost(rctx, split, channel, model.CreatePostFlags{}); err != nil { + if _, _, err := a.CreatePost(rctx, split, channel, model.CreatePostFlags{}); err != nil { return nil, model.NewAppError("CreateWebhookPost", "api.post.create_webhook_post.creating.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } } @@ -847,7 +847,12 @@ func (a *App) HandleIncomingWebhook(rctx request.CTX, hookID string, req *model. return model.NewAppError("HandleIncomingWebhook", "web.incoming_webhook.user.app_error", map[string]any{"user": hook.UserId}, "", http.StatusForbidden).Wrap(resultU.NErr) } - if channel.Type != model.ChannelTypeOpen && !a.HasPermissionToChannel(rctx, hook.UserId, channel.Id, model.PermissionReadChannelContent) { + restrictedChannel := false + if channel.Type != model.ChannelTypeOpen { + hasPermission, _ := a.HasPermissionToChannel(rctx, hook.UserId, channel.Id, model.PermissionReadChannelContent) + restrictedChannel = !hasPermission + } + if restrictedChannel { return model.NewAppError("HandleIncomingWebhook", "web.incoming_webhook.permissions.app_error", map[string]any{"user": hook.UserId, "channel": channel.Id}, "", http.StatusForbidden) } diff --git a/server/channels/db/migrations/migrations.list b/server/channels/db/migrations/migrations.list index 76514c51563..1eaa033e3ad 100644 --- a/server/channels/db/migrations/migrations.list +++ b/server/channels/db/migrations/migrations.list @@ -293,3 +293,5 @@ channels/db/migrations/postgres/000147_create_autotranslation_tables.down.sql channels/db/migrations/postgres/000147_create_autotranslation_tables.up.sql channels/db/migrations/postgres/000148_add_burn_on_read_messages.down.sql channels/db/migrations/postgres/000148_add_burn_on_read_messages.up.sql +channels/db/migrations/postgres/000149_create_recaps.down.sql +channels/db/migrations/postgres/000149_create_recaps.up.sql diff --git a/server/channels/db/migrations/postgres/000149_create_recaps.down.sql b/server/channels/db/migrations/postgres/000149_create_recaps.down.sql new file mode 100644 index 00000000000..8f7ff70d648 --- /dev/null +++ b/server/channels/db/migrations/postgres/000149_create_recaps.down.sql @@ -0,0 +1,12 @@ +DROP INDEX IF EXISTS idx_recap_channels_channel_id; +DROP INDEX IF EXISTS idx_recap_channels_recap_id; +DROP TABLE IF EXISTS RecapChannels; + +DROP INDEX IF EXISTS idx_recaps_bot_id; +DROP INDEX IF EXISTS idx_recaps_user_id_read_at; +DROP INDEX IF EXISTS idx_recaps_user_id_delete_at; +DROP INDEX IF EXISTS idx_recaps_create_at; +DROP INDEX IF EXISTS idx_recaps_user_id; +DROP TABLE IF EXISTS Recaps; + + diff --git a/server/channels/db/migrations/postgres/000149_create_recaps.up.sql b/server/channels/db/migrations/postgres/000149_create_recaps.up.sql new file mode 100644 index 00000000000..b54a3abfc40 --- /dev/null +++ b/server/channels/db/migrations/postgres/000149_create_recaps.up.sql @@ -0,0 +1,37 @@ +-- Recaps table: stores recap metadata +CREATE TABLE IF NOT EXISTS Recaps ( + Id VARCHAR(26) PRIMARY KEY, + UserId VARCHAR(26) NOT NULL, + Title VARCHAR(255) NOT NULL, + CreateAt BIGINT NOT NULL, + UpdateAt BIGINT NOT NULL, + DeleteAt BIGINT NOT NULL, + TotalMessageCount INT NOT NULL, + Status VARCHAR(32) NOT NULL, + ReadAt BIGINT DEFAULT 0 NOT NULL, + BotID VARCHAR(26) DEFAULT '' NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_recaps_user_id ON Recaps(UserId); +CREATE INDEX IF NOT EXISTS idx_recaps_create_at ON Recaps(CreateAt); +CREATE INDEX IF NOT EXISTS idx_recaps_user_id_delete_at ON Recaps(UserId, DeleteAt); +CREATE INDEX IF NOT EXISTS idx_recaps_user_id_read_at ON Recaps(UserId, ReadAt); +CREATE INDEX IF NOT EXISTS idx_recaps_bot_id ON Recaps(BotID); + +-- RecapChannels table: stores per-channel summaries +CREATE TABLE IF NOT EXISTS RecapChannels ( + Id VARCHAR(26) PRIMARY KEY, + RecapId VARCHAR(26) NOT NULL, + ChannelId VARCHAR(26) NOT NULL, + ChannelName VARCHAR(64) NOT NULL, + Highlights TEXT, + ActionItems TEXT, + SourcePostIds TEXT, + CreateAt BIGINT NOT NULL, + FOREIGN KEY (RecapId) REFERENCES Recaps(Id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_recap_channels_recap_id ON RecapChannels(RecapId); +CREATE INDEX IF NOT EXISTS idx_recap_channels_channel_id ON RecapChannels(ChannelId); + + diff --git a/server/channels/jobs/recap/worker.go b/server/channels/jobs/recap/worker.go new file mode 100644 index 00000000000..f5997d43a53 --- /dev/null +++ b/server/channels/jobs/recap/worker.go @@ -0,0 +1,129 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package recap + +import ( + "fmt" + "strings" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/jobs" + "github.com/mattermost/mattermost/server/v8/channels/store" +) + +type AppIface interface { + ProcessRecapChannel(rctx request.CTX, recapID, channelID, userID, agentID string) (*model.RecapChannelResult, *model.AppError) + Publish(message *model.WebSocketEvent) +} + +func MakeWorker(jobServer *jobs.JobServer, storeInstance store.Store, appInstance AppIface) *jobs.SimpleWorker { + isEnabled := func(cfg *model.Config) bool { + return cfg.FeatureFlags.EnableAIRecaps + } + + execute := func(logger mlog.LoggerIFace, job *model.Job) error { + defer jobServer.HandleJobPanic(logger, job) + return processRecapJob(logger, job, storeInstance, appInstance, func(progress int64) { + _ = jobServer.SetJobProgress(job, progress) + }) + } + + return jobs.NewSimpleWorker("Recap", jobServer, execute, isEnabled) +} + +func processRecapJob(logger mlog.LoggerIFace, job *model.Job, storeInstance store.Store, appInstance AppIface, setProgress func(int64)) error { + recapID := job.Data["recap_id"] + userID := job.Data["user_id"] + channelIDs := strings.Split(job.Data["channel_ids"], ",") + agentID := job.Data["agent_id"] + + logger.Info("Starting recap job", + mlog.String("recap_id", recapID), + mlog.String("agent_id", agentID), + mlog.Int("channel_count", len(channelIDs))) + + // Update status to processing + _ = storeInstance.Recap().UpdateRecapStatus(recapID, model.RecapStatusProcessing) + publishRecapUpdate(appInstance, recapID, userID) + + totalMessages := 0 + successfulChannels := []string{} + failedChannels := []string{} + + for i, channelID := range channelIDs { + // Update progress + progress := int64((i * 100) / len(channelIDs)) + if setProgress != nil { + setProgress(progress) + } + + // Process the channel + result, err := appInstance.ProcessRecapChannel(request.EmptyContext(logger), recapID, channelID, userID, agentID) + if err != nil { + logger.Warn("Failed to process channel", + mlog.String("channel_id", channelID), + mlog.Err(err)) + failedChannels = append(failedChannels, channelID) + continue + } + + if !result.Success { + logger.Warn("Channel processing unsuccessful", mlog.String("channel_id", channelID)) + failedChannels = append(failedChannels, channelID) + continue + } + + totalMessages += result.MessageCount + successfulChannels = append(successfulChannels, channelID) + } + + // Update recap with final data (title is already set by user in CreateRecap) + recap, _ := storeInstance.Recap().GetRecap(recapID) + recap.TotalMessageCount = totalMessages + recap.UpdateAt = model.GetMillis() + + if len(failedChannels) > 0 && len(successfulChannels) == 0 { + recap.Status = model.RecapStatusFailed + _, err := storeInstance.Recap().UpdateRecap(recap) + if err != nil { + logger.Error("Failed to update recap", mlog.Err(err)) + return fmt.Errorf("failed to update recap: %w", err) + } + publishRecapUpdate(appInstance, recapID, userID) + return fmt.Errorf("all channels failed to process") + } else if len(failedChannels) > 0 { + recap.Status = model.RecapStatusCompleted + _, err := storeInstance.Recap().UpdateRecap(recap) + if err != nil { + logger.Error("Failed to update recap", mlog.Err(err)) + return fmt.Errorf("failed to update recap: %w", err) + } + publishRecapUpdate(appInstance, recapID, userID) + logger.Warn("Some channels failed", mlog.Int("failed_count", len(failedChannels))) + // Job succeeds with warning + } else { + recap.Status = model.RecapStatusCompleted + _, err := storeInstance.Recap().UpdateRecap(recap) + if err != nil { + logger.Error("Failed to update recap", mlog.Err(err)) + return fmt.Errorf("failed to update recap: %w", err) + } + publishRecapUpdate(appInstance, recapID, userID) + } + + logger.Info("Recap job completed", + mlog.String("recap_id", recapID), + mlog.Int("successful_channels", len(successfulChannels)), + mlog.Int("failed_channels", len(failedChannels))) + + return nil +} + +func publishRecapUpdate(appInstance AppIface, recapID, userID string) { + message := model.NewWebSocketEvent(model.WebsocketEventRecapUpdated, "", "", userID, nil, "") + message.Add("recap_id", recapID) + appInstance.Publish(message) +} diff --git a/server/channels/jobs/recap/worker_test.go b/server/channels/jobs/recap/worker_test.go new file mode 100644 index 00000000000..35916641b53 --- /dev/null +++ b/server/channels/jobs/recap/worker_test.go @@ -0,0 +1,128 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package recap + +import ( + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +type MockAppIface struct { + mock.Mock +} + +func (m *MockAppIface) ProcessRecapChannel(rctx request.CTX, recapID, channelID, userID, agentID string) (*model.RecapChannelResult, *model.AppError) { + args := m.Called(rctx, recapID, channelID, userID, agentID) + if args.Get(0) == nil { + return nil, args.Get(1).(*model.AppError) + } + return args.Get(0).(*model.RecapChannelResult), nil +} + +func (m *MockAppIface) Publish(message *model.WebSocketEvent) { + m.Called(message) +} + +func TestProcessRecapJob(t *testing.T) { + logger := mlog.CreateConsoleTestLogger(t) + job := &model.Job{ + Data: map[string]string{ + "recap_id": "recap1", + "user_id": "user1", + "channel_ids": "channel1,channel2", + "agent_id": "agent1", + }, + } + + t.Run("successful processing", func(t *testing.T) { + mockStore := &mocks.Store{} + mockRecapStore := &mocks.RecapStore{} + mockStore.On("Recap").Return(mockRecapStore) + + mockApp := &MockAppIface{} + + // Setup expectations + mockRecapStore.On("UpdateRecapStatus", "recap1", model.RecapStatusProcessing).Return(nil) + mockApp.On("Publish", mock.Anything).Return() + + mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel1", "user1", "agent1").Return(&model.RecapChannelResult{ + ChannelID: "channel1", + Success: true, + MessageCount: 10, + }, nil) + + mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel2", "user1", "agent1").Return(&model.RecapChannelResult{ + ChannelID: "channel2", + Success: true, + MessageCount: 5, + }, nil) + + recap := &model.Recap{Id: "recap1"} + mockRecapStore.On("GetRecap", "recap1").Return(recap, nil) + mockRecapStore.On("UpdateRecap", mock.MatchedBy(func(r *model.Recap) bool { + return r.TotalMessageCount == 15 && r.Status == model.RecapStatusCompleted + })).Return(recap, nil) + + err := processRecapJob(logger, job, mockStore, mockApp, nil) + require.NoError(t, err) + }) + + t.Run("partial failure", func(t *testing.T) { + mockStore := &mocks.Store{} + mockRecapStore := &mocks.RecapStore{} + mockStore.On("Recap").Return(mockRecapStore) + + mockApp := &MockAppIface{} + + mockRecapStore.On("UpdateRecapStatus", "recap1", model.RecapStatusProcessing).Return(nil) + mockApp.On("Publish", mock.Anything).Return() + + mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel1", "user1", "agent1").Return(&model.RecapChannelResult{ + ChannelID: "channel1", + Success: true, + MessageCount: 10, + }, nil) + + mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel2", "user1", "agent1").Return(nil, model.NewAppError("fail", "fail", nil, "", 500)) + + recap := &model.Recap{Id: "recap1"} + mockRecapStore.On("GetRecap", "recap1").Return(recap, nil) + mockRecapStore.On("UpdateRecap", mock.MatchedBy(func(r *model.Recap) bool { + return r.TotalMessageCount == 10 && r.Status == model.RecapStatusCompleted + })).Return(recap, nil) + + err := processRecapJob(logger, job, mockStore, mockApp, nil) + require.NoError(t, err) + }) + + t.Run("complete failure", func(t *testing.T) { + mockStore := &mocks.Store{} + mockRecapStore := &mocks.RecapStore{} + mockStore.On("Recap").Return(mockRecapStore) + + mockApp := &MockAppIface{} + + mockRecapStore.On("UpdateRecapStatus", "recap1", model.RecapStatusProcessing).Return(nil) + mockApp.On("Publish", mock.Anything).Return() + + mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel1", "user1", "agent1").Return(nil, model.NewAppError("fail", "fail", nil, "", 500)) + mockApp.On("ProcessRecapChannel", mock.Anything, "recap1", "channel2", "user1", "agent1").Return(nil, model.NewAppError("fail", "fail", nil, "", 500)) + + recap := &model.Recap{Id: "recap1"} + mockRecapStore.On("GetRecap", "recap1").Return(recap, nil) + mockRecapStore.On("UpdateRecap", mock.MatchedBy(func(r *model.Recap) bool { + return r.TotalMessageCount == 0 && r.Status == model.RecapStatusFailed + })).Return(recap, nil) + + err := processRecapJob(logger, job, mockStore, mockApp, nil) + require.Error(t, err) + require.Equal(t, "all channels failed to process", err.Error()) + }) +} diff --git a/server/channels/store/searchlayer/stop_word.go b/server/channels/store/searchlayer/stop_word.go deleted file mode 100644 index 12bcc6e68dc..00000000000 --- a/server/channels/store/searchlayer/stop_word.go +++ /dev/null @@ -1,7 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -package searchlayer - -var MySQLStopWords = []string{"a", "about", "an", "are", "as", "at", "be", "by", "com", "de", "en", "for", "from", "how", "i", "in", "is", "it", "la", "of", - "on", "or", "that", "the", "this", "to", "was", "what", "when", "where", "who", "will", "with", "und", "the", "www"} diff --git a/server/channels/store/searchtest/post_layer.go b/server/channels/store/searchtest/post_layer.go index 7d8eeef4d40..34c8b0be751 100644 --- a/server/channels/store/searchtest/post_layer.go +++ b/server/channels/store/searchtest/post_layer.go @@ -41,17 +41,10 @@ var searchPostStoreTests = []searchTest{ Tags: []string{EnginePostgres}, }, { - // Postgres supports search with and without quotes Name: "Should be able to search for email addresses with or without quotes", Fn: testSearchEmailAddresses, Tags: []string{EnginePostgres, EngineElasticSearch}, }, - { - // MySql supports search with quotes only - Name: "Should be able to search for email addresses with quotes", - Fn: testSearchEmailAddressesWithQuotes, - Tags: []string{EngineElasticSearch}, - }, { Name: "Should be able to search when markdown underscores are applied", Fn: testSearchMarkdownUnderscores, @@ -557,21 +550,6 @@ func testSearchEmailAddresses(t *testing.T, th *SearchTestHelper) { }) } -func testSearchEmailAddressesWithQuotes(t *testing.T, th *SearchTestHelper) { - p1, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "email test@test.com", "", model.PostTypeDefault, 0, false) - require.NoError(t, err) - _, err = th.createPost(th.User.Id, th.ChannelBasic.Id, "email test2@test.com", "", model.PostTypeDefault, 0, false) - require.NoError(t, err) - defer th.deleteUserPosts(th.User.Id) - - params := &model.SearchParams{Terms: "\"test@test.com\""} - results, err := th.Store.Post().SearchPostsForUser(th.Context, []*model.SearchParams{params}, th.User.Id, th.Team.Id, 0, 20) - require.NoError(t, err) - - require.Len(t, results.Posts, 1) - th.checkPostInSearchResults(t, p1.Id, results.Posts) -} - func testSearchMarkdownUnderscores(t *testing.T, th *SearchTestHelper) { p1, err := th.createPost(th.User.Id, th.ChannelBasic.Id, "_start middle end_ _another_", "", model.PostTypeDefault, 0, false) require.NoError(t, err) diff --git a/server/channels/store/sqlstore/access_control_policy_store.go b/server/channels/store/sqlstore/access_control_policy_store.go index 06121074c80..fa52eddf973 100644 --- a/server/channels/store/sqlstore/access_control_policy_store.go +++ b/server/channels/store/sqlstore/access_control_policy_store.go @@ -359,12 +359,7 @@ func (s *SqlAccessControlPolicyStore) SetActiveStatus(rctx request.CTX, id strin if existingPolicy.Type == model.AccessControlPolicyTypeParent { // if the policy is a parent, we need to update the child policies - var expr sq.Sqlizer - if s.DriverName() == model.DatabaseDriverPostgres { - expr = sq.Expr("Data->'imports' @> ?::jsonb", fmt.Sprintf("%q", id)) - } else { - expr = sq.Expr("JSON_CONTAINS(JSON_EXTRACT(Data, '$.imports'), ?)", fmt.Sprintf("%q", id)) - } + expr := sq.Expr("Data->'imports' @> ?::jsonb", fmt.Sprintf("%q", id)) query, args, err = s.getQueryBuilder().Update("AccessControlPolicies").Set("Active", active).Where(expr).ToSql() if err != nil { return nil, errors.Wrapf(err, "failed to build query for policy with id=%s", id) @@ -541,11 +536,7 @@ func (s *SqlAccessControlPolicyStore) GetAll(_ request.CTX, opts model.GetAccess query := s.selectQueryBuilder if opts.ParentID != "" { - if s.DriverName() == model.DatabaseDriverPostgres { - query = query.Where(sq.Expr("Data->'imports' @> ?", fmt.Sprintf("%q", opts.ParentID))) - } else { - query = query.Where(sq.Expr("JSON_CONTAINS(JSON_EXTRACT(Data, '$.imports'), ?)", fmt.Sprintf("%q", opts.ParentID))) - } + query = query.Where(sq.Expr("Data->'imports' @> ?", fmt.Sprintf("%q", opts.ParentID))) } if opts.Type != "" { diff --git a/server/channels/store/sqlstore/attributes_store.go b/server/channels/store/sqlstore/attributes_store.go index 466d4a52a86..f73007517e6 100644 --- a/server/channels/store/sqlstore/attributes_store.go +++ b/server/channels/store/sqlstore/attributes_store.go @@ -51,10 +51,8 @@ func newSqlAttributesStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterf } func (s *SqlAttributesStore) RefreshAttributes() error { - if s.DriverName() == model.DatabaseDriverPostgres { - if _, err := s.GetMaster().Exec("REFRESH MATERIALIZED VIEW AttributeView"); err != nil { - return errors.Wrap(err, "error refreshing materialized view AttributeView") - } + if _, err := s.GetMaster().Exec("REFRESH MATERIALIZED VIEW AttributeView"); err != nil { + return errors.Wrap(err, "error refreshing materialized view AttributeView") } return nil @@ -143,8 +141,8 @@ func (s *SqlAttributesStore) SearchUsers(rctx request.CTX, opts model.SubjectSea } if term := opts.Term; strings.TrimSpace(term) != "" { - _, query = generateSearchQueryForExpression(query, strings.Fields(term), searchFields, s.DriverName() == model.DatabaseDriverPostgres, argCount) - _, count = generateSearchQueryForExpression(count, strings.Fields(term), searchFields, s.DriverName() == model.DatabaseDriverPostgres, argCount) + _, query = generateSearchQueryForExpression(query, strings.Fields(term), searchFields, argCount) + _, count = generateSearchQueryForExpression(count, strings.Fields(term), searchFields, argCount) } q, args, err := query.ToSql() @@ -211,25 +209,17 @@ func (s *SqlAttributesStore) GetChannelMembersToRemove(rctx request.CTX, channel return members, nil } -func generateSearchQueryForExpression(query sq.SelectBuilder, terms []string, fields []string, isPostgreSQL bool, prevArgs int) (int, sq.SelectBuilder) { +func generateSearchQueryForExpression(query sq.SelectBuilder, terms []string, fields []string, prevArgs int) (int, sq.SelectBuilder) { for _, term := range terms { searchFields := []string{} termArgs := []any{} for _, field := range fields { - if isPostgreSQL { - prevArgs++ - searchFields = append(searchFields, fmt.Sprintf("lower(%s) LIKE lower($%d) escape '*' ", field, prevArgs)) - } else { - searchFields = append(searchFields, fmt.Sprintf("%s LIKE ? escape '*' ", field)) - } + prevArgs++ + searchFields = append(searchFields, fmt.Sprintf("lower(%s) LIKE lower($%d) escape '*' ", field, prevArgs)) termArgs = append(termArgs, fmt.Sprintf("%%%s%%", strings.TrimLeft(term, "@"))) } - if isPostgreSQL { - prevArgs++ - searchFields = append(searchFields, fmt.Sprintf("lower(%s) LIKE lower($%d) escape '*' ", "Id", prevArgs)) - } else { - searchFields = append(searchFields, "Id = ?") - } + prevArgs++ + searchFields = append(searchFields, fmt.Sprintf("lower(%s) LIKE lower($%d) escape '*' ", "Id", prevArgs)) termArgs = append(termArgs, strings.TrimLeft(term, "@")) query = query.Where(fmt.Sprintf("(%s)", strings.Join(searchFields, " OR ")), termArgs...) } diff --git a/server/channels/store/sqlstore/channel_member_history_store.go b/server/channels/store/sqlstore/channel_member_history_store.go index 89a7e5cff16..7665297c379 100644 --- a/server/channels/store/sqlstore/channel_member_history_store.go +++ b/server/channels/store/sqlstore/channel_member_history_store.go @@ -248,16 +248,12 @@ func (s SqlChannelMemberHistoryStore) PermanentDeleteBatchForRetentionPolicies(r // DeleteOrphanedRows removes entries from ChannelMemberHistory when a corresponding channel no longer exists. func (s SqlChannelMemberHistoryStore) DeleteOrphanedRows(limit int) (deleted int64, err error) { - // TODO: https://mattermost.atlassian.net/browse/MM-63368 - // We need the extra level of nesting to deal with MySQL's locking const query = ` - DELETE FROM ChannelMemberHistory WHERE (ChannelId, UserId, JoinTime) IN ( - SELECT ChannelId, UserId, JoinTime FROM ( - SELECT ChannelId, UserId, JoinTime FROM ChannelMemberHistory - LEFT JOIN Channels ON ChannelMemberHistory.ChannelId = Channels.Id - WHERE Channels.Id IS NULL - LIMIT ? - ) AS A + DELETE FROM ChannelMemberHistory WHERE ctid IN ( + SELECT ChannelMemberHistory.ctid FROM ChannelMemberHistory + LEFT JOIN Channels ON ChannelMemberHistory.ChannelId = Channels.Id + WHERE Channels.Id IS NULL + LIMIT $1 )` result, err := s.GetMaster().Exec(query, limit) if err != nil { @@ -268,39 +264,22 @@ func (s SqlChannelMemberHistoryStore) DeleteOrphanedRows(limit int) (deleted int } func (s SqlChannelMemberHistoryStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) { - var ( - query string - args []any - err error - ) - - if s.DriverName() == model.DatabaseDriverPostgres { - var innerSelect string - innerSelect, args, err = s.getQueryBuilder(). - Select("ctid"). - From("ChannelMemberHistory"). - Where(sq.And{ - sq.NotEq{"LeaveTime": nil}, - sq.LtOrEq{"LeaveTime": endTime}, - }).Limit(uint64(limit)). - ToSql() - if err != nil { - return 0, errors.Wrap(err, "channel_member_history_to_sql") - } - query, _, err = s.getQueryBuilder(). - Delete("ChannelMemberHistory"). - Where(fmt.Sprintf( - "ctid IN (%s)", innerSelect, - )).ToSql() - } else { - query, args, err = s.getQueryBuilder(). - Delete("ChannelMemberHistory"). - Where(sq.And{ - sq.NotEq{"LeaveTime": nil}, - sq.LtOrEq{"LeaveTime": endTime}, - }). - Limit(uint64(limit)).ToSql() + innerSelect, args, err := s.getQueryBuilder(). + Select("ctid"). + From("ChannelMemberHistory"). + Where(sq.And{ + sq.NotEq{"LeaveTime": nil}, + sq.LtOrEq{"LeaveTime": endTime}, + }).Limit(uint64(limit)). + ToSql() + if err != nil { + return 0, errors.Wrap(err, "channel_member_history_to_sql") } + query, _, err := s.getQueryBuilder(). + Delete("ChannelMemberHistory"). + Where(fmt.Sprintf( + "ctid IN (%s)", innerSelect, + )).ToSql() if err != nil { return 0, errors.Wrap(err, "channel_member_history_to_sql") } diff --git a/server/channels/store/sqlstore/channel_store.go b/server/channels/store/sqlstore/channel_store.go index 3feea528045..0883ba73fbe 100644 --- a/server/channels/store/sqlstore/channel_store.go +++ b/server/channels/store/sqlstore/channel_store.go @@ -2531,51 +2531,44 @@ func (s SqlChannelStore) PermanentDeleteMembersByUser(rctx request.CTX, userId s func (s SqlChannelStore) UpdateLastViewedAt(channelIds []string, userId string) (map[string]int64, error) { lastPostAtTimes := []struct { - Id string - LastPostAt int64 - TotalMsgCount int64 - TotalMsgCountRoot int64 + Id string + LastPostAt int64 }{} if len(channelIds) == 0 { return map[string]int64{}, nil } - // We use the question placeholder format for both databases, because - // we replace that with the dollar format later on. - // It's needed to support the prefix CTE query. See: https://github.com/Masterminds/squirrel/issues/285. + // We use the question placeholder format because we replace it with the + // dollar format later on. It's needed to support the prefix CTE query. + // See: https://github.com/Masterminds/squirrel/issues/285. query := sq.StatementBuilder.PlaceholderFormat(sq.Question). Select("Id, LastPostAt, TotalMsgCount, TotalMsgCountRoot"). From("Channels"). Where(sq.Eq{"Id": channelIds}) - // TODO: use a CTE for mysql too when version 8 becomes the minimum supported version. - if s.DriverName() == model.DatabaseDriverPostgres { - with := query.Prefix("WITH c AS (").Suffix(") ,") - update := sq.StatementBuilder.PlaceholderFormat(sq.Question). - Update("ChannelMembers cm"). - Set("MentionCount", 0). - Set("MentionCountRoot", 0). - Set("UrgentMentionCount", 0). - Set("MsgCount", sq.Expr("greatest(cm.MsgCount, c.TotalMsgCount)")). - Set("MsgCountRoot", sq.Expr("greatest(cm.MsgCountRoot, c.TotalMsgCountRoot)")). - Set("LastViewedAt", sq.Expr("greatest(cm.LastViewedAt, c.LastPostAt)")). - Set("LastUpdateAt", sq.Expr("greatest(cm.LastViewedAt, c.LastPostAt)")). - SuffixExpr(sq.Expr("FROM c WHERE cm.UserId = ? AND c.Id = cm.ChannelId", userId)) - updateWrap := update.Prefix("updated AS (").Suffix(")") - query = with.SuffixExpr(updateWrap).Suffix("SELECT Id, LastPostAt FROM c") - } + with := query.Prefix("WITH c AS (").Suffix(") ,") + update := sq.StatementBuilder.PlaceholderFormat(sq.Question). + Update("ChannelMembers cm"). + Set("MentionCount", 0). + Set("MentionCountRoot", 0). + Set("UrgentMentionCount", 0). + Set("MsgCount", sq.Expr("greatest(cm.MsgCount, c.TotalMsgCount)")). + Set("MsgCountRoot", sq.Expr("greatest(cm.MsgCountRoot, c.TotalMsgCountRoot)")). + Set("LastViewedAt", sq.Expr("greatest(cm.LastViewedAt, c.LastPostAt)")). + Set("LastUpdateAt", sq.Expr("greatest(cm.LastViewedAt, c.LastPostAt)")). + SuffixExpr(sq.Expr("FROM c WHERE cm.UserId = ? AND c.Id = cm.ChannelId", userId)) + updateWrap := update.Prefix("updated AS (").Suffix(")") + query = with.SuffixExpr(updateWrap).Suffix("SELECT Id, LastPostAt FROM c") sql, args, err := query.ToSql() if err != nil { return nil, errors.Wrap(err, "UpdateLastViewedAt_CTE_Tosql") } - if s.DriverName() == model.DatabaseDriverPostgres { - sql, err = sq.Dollar.ReplacePlaceholders(sql) - if err != nil { - return nil, errors.Wrap(err, "UpdateLastViewedAt_ReplacePlaceholders") - } + sql, err = sq.Dollar.ReplacePlaceholders(sql) + if err != nil { + return nil, errors.Wrap(err, "UpdateLastViewedAt_ReplacePlaceholders") } err = s.GetMaster().Select(&lastPostAtTimes, sql, args...) @@ -2588,53 +2581,9 @@ func (s SqlChannelStore) UpdateLastViewedAt(channelIds []string, userId string) } times := map[string]int64{} - if s.DriverName() == model.DatabaseDriverPostgres { - for _, t := range lastPostAtTimes { - times[t.Id] = t.LastPostAt - } - return times, nil - } - - msgCountQuery, msgCountQueryRoot, lastViewedQuery := sq.Case("ChannelId"), sq.Case("ChannelId"), sq.Case("ChannelId") - for _, t := range lastPostAtTimes { times[t.Id] = t.LastPostAt - - msgCountQuery = msgCountQuery.When( - sq.Expr("?", t.Id), - sq.Expr("GREATEST(MsgCount, ?)", t.TotalMsgCount)) - - msgCountQueryRoot = msgCountQueryRoot.When( - sq.Expr("?", t.Id), - sq.Expr("GREATEST(MsgCountRoot, ?)", t.TotalMsgCountRoot)) - - lastViewedQuery = lastViewedQuery.When( - sq.Expr("?", t.Id), - sq.Expr("GREATEST(LastViewedAt, ?)", t.LastPostAt)) } - - updateQuery := s.getQueryBuilder().Update("ChannelMembers"). - Set("MentionCount", 0). - Set("MentionCountRoot", 0). - Set("UrgentMentionCount", 0). - Set("MsgCount", msgCountQuery). - Set("MsgCountRoot", msgCountQueryRoot). - Set("LastViewedAt", lastViewedQuery). - Set("LastUpdateAt", sq.Expr("LastViewedAt")). - Where(sq.Eq{ - "UserId": userId, - "ChannelId": channelIds, - }) - - sql, args, err = updateQuery.ToSql() - if err != nil { - return nil, errors.Wrap(err, "UpdateLastViewedAt_Update_Tosql") - } - - if _, err := s.GetMaster().Exec(sql, args...); err != nil { - return nil, errors.Wrapf(err, "failed to update ChannelMembers with userId=%s and channelId in %v", userId, channelIds) - } - return times, nil } @@ -3082,11 +3031,12 @@ func (s SqlChannelStore) Autocomplete(rctx request.CTX, userID, term string, inc OrderBy("c.DisplayName"). Limit(model.ChannelSearchDefaultLimit) + // Always filter out soft-deleted team memberships - users removed from + // a team should not see channels from that team regardless of includeDeleted + query = query.Where(sq.Eq{"tm.DeleteAt": 0}) + if !includeDeleted { - query = query.Where(sq.And{ - sq.Eq{"c.DeleteAt": 0}, - sq.Eq{"tm.DeleteAt": 0}, - }) + query = query.Where(sq.Eq{"c.DeleteAt": 0}) } if isGuest { @@ -3198,7 +3148,7 @@ func (s SqlChannelStore) AutocompleteInTeamForSearch(teamID string, userID strin } } else { // build the full text search clause - full := s.buildFulltextClauseX(term, "Name", "DisplayName", "Purpose") + full := s.buildFulltextClause(term, "Name", "DisplayName", "Purpose") // build the LIKE query likeSQL, likeArgs, err := query.Where(like).ToSql() if err != nil { @@ -3217,15 +3167,11 @@ func (s SqlChannelStore) AutocompleteInTeamForSearch(teamID string, userID strin args = append(likeArgs, fullArgs...) } - var err error - // since the UNION is not part of squirrel, we need to assemble it and then update // the placeholders manually - if s.DriverName() == model.DatabaseDriverPostgres { - sql, err = sq.Dollar.ReplacePlaceholders(sql) - if err != nil { - return nil, errors.Wrap(err, "AutocompleteInTeamForSearch_Placeholder") - } + sql, err := sq.Dollar.ReplacePlaceholders(sql) + if err != nil { + return nil, errors.Wrap(err, "AutocompleteInTeamForSearch_Placeholder") } // query the database @@ -3392,13 +3338,9 @@ func (s SqlChannelStore) channelSearchQuery(opts *store.ChannelSearchOpts) sq.Se InnerJoin("RetentionPoliciesChannels ON c.Id = RetentionPoliciesChannels.ChannelId"). Where(sq.Eq{"RetentionPoliciesChannels.PolicyId": opts.PolicyID}) } else if opts.ExcludePolicyConstrained { - if s.DriverName() == model.DatabaseDriverPostgres { - query = query. - LeftJoin("RetentionPoliciesChannels ON c.Id = RetentionPoliciesChannels.ChannelId"). - Where("RetentionPoliciesChannels.ChannelId IS NULL") - } else { - query = query.Where(sq.Expr(`c.Id NOT IN (SELECT ChannelId FROM RetentionPoliciesChannels)`)) - } + query = query. + LeftJoin("RetentionPoliciesChannels ON c.Id = RetentionPoliciesChannels.ChannelId"). + Where("RetentionPoliciesChannels.ChannelId IS NULL") } else if opts.IncludePolicyID { query = query. LeftJoin("RetentionPoliciesChannels ON c.Id = RetentionPoliciesChannels.ChannelId") @@ -3419,12 +3361,10 @@ func (s SqlChannelStore) channelSearchQuery(opts *store.ChannelSearchOpts) sq.Se likeTerms[i] = likeTerm } likeClause = strings.ReplaceAll(likeClause, ":LikeTerm", "?") - fulltextClause, fulltextTerm := s.buildFulltextClause(opts.Term, "c.Name, c.DisplayName, c.Purpose") - fulltextClause = strings.ReplaceAll(fulltextClause, ":FulltextTerm", "?") query = query.Where(sq.Or{ sq.Expr(likeClause, likeTerms...), - sq.Expr(fulltextClause, fulltextTerm), + s.buildFulltextClause(opts.Term, "c.Name", "c.DisplayName", "c.Purpose"), }) } @@ -3473,11 +3413,7 @@ func (s SqlChannelStore) channelSearchQuery(opts *store.ChannelSearchOpts) sq.Se if opts.ExcludeAccessControlPolicyEnforced { query = query.Where("c.Id NOT IN (SELECT ID From AccessControlPolicies WHERE Type = ?)", model.AccessControlPolicyTypeChannel) } else if opts.ParentAccessControlPolicyId != "" { - if s.DriverName() == model.DatabaseDriverPostgres { - query = query.Where(sq.Expr("c.Id IN (SELECT ID From AccessControlPolicies WHERE Type = ? AND Data->'imports' @> ?)", model.AccessControlPolicyTypeChannel, fmt.Sprintf("%q", opts.ParentAccessControlPolicyId))) - } else { - query = query.Where(sq.Expr("c.Id IN (SELECT ID From AccessControlPolicies WHERE Type = ? AND JSON_CONTAINS(JSON_EXTRACT(Data, '$.imports'), ?))", model.AccessControlPolicyTypeChannel, fmt.Sprintf("%q", opts.ParentAccessControlPolicyId))) - } + query = query.Where(sq.Expr("c.Id IN (SELECT ID From AccessControlPolicies WHERE Type = ? AND Data->'imports' @> ?)", model.AccessControlPolicyTypeChannel, fmt.Sprintf("%q", opts.ParentAccessControlPolicyId))) } else if opts.AccessControlPolicyEnforced { query = query.InnerJoin("AccessControlPolicies acp ON acp.ID = c.Id") } @@ -3555,11 +3491,7 @@ func (s SqlChannelStore) buildLIKEClause(term string, searchColumns string) (lik // Prepare the LIKE portion of the query. var searchFields []string for field := range strings.SplitSeq(searchColumns, ", ") { - if s.DriverName() == model.DatabaseDriverPostgres { - searchFields = append(searchFields, fmt.Sprintf("lower(%s) LIKE lower(%s) escape '*'", field, ":LikeTerm")) - } else { - searchFields = append(searchFields, fmt.Sprintf("%s LIKE %s escape '*'", field, ":LikeTerm")) - } + searchFields = append(searchFields, fmt.Sprintf("lower(%s) LIKE lower(%s) escape '*'", field, ":LikeTerm")) } likeClause = fmt.Sprintf("(%s)", strings.Join(searchFields, " OR ")) @@ -3581,13 +3513,8 @@ func (s SqlChannelStore) buildLIKEClauseX(term string, searchColumns ...string) var searchFields sq.Or for _, field := range searchColumns { - if s.DriverName() == model.DatabaseDriverPostgres { - expr := fmt.Sprintf("LOWER(%s) LIKE LOWER(?) ESCAPE '*'", field) - searchFields = append(searchFields, sq.Expr(expr, likeTerm)) - } else { - expr := fmt.Sprintf("%s LIKE ? ESCAPE '*'", field) - searchFields = append(searchFields, sq.Expr(expr, likeTerm)) - } + expr := fmt.Sprintf("LOWER(%s) LIKE LOWER(?) ESCAPE '*'", field) + searchFields = append(searchFields, sq.Expr(expr, likeTerm)) } return searchFields @@ -3595,71 +3522,28 @@ func (s SqlChannelStore) buildLIKEClauseX(term string, searchColumns ...string) const spaceFulltextSearchChars = "<>+-()~:*\"!@&" -func (s SqlChannelStore) buildFulltextClause(term string, searchColumns string) (fulltextClause, fulltextTerm string) { - // Copy the terms as we will need to prepare them differently for each search type. - fulltextTerm = term - +func (s SqlChannelStore) buildFulltextClause(term string, searchColumns ...string) sq.Sqlizer { // These chars must be treated as spaces in the fulltext query. - fulltextTerm = strings.Map(func(r rune) rune { + fulltextTerm := strings.Map(func(r rune) rune { if strings.ContainsRune(spaceFulltextSearchChars, r) { return ' ' } return r - }, fulltextTerm) + }, term) - // Prepare the FULLTEXT portion of the query. + // Remove all pipes | fulltextTerm = strings.ReplaceAll(fulltextTerm, "|", "") + // Split the search term and append :* to each part for prefix matching splitTerm := strings.Fields(fulltextTerm) - for i, t := range strings.Fields(fulltextTerm) { + for i, t := range splitTerm { splitTerm[i] = t + ":*" } + // Join the search terms with & for AND matching fulltextTerm = strings.Join(splitTerm, " & ") - fulltextClause = fmt.Sprintf("((to_tsvector('%[1]s', %[2]s)) @@ to_tsquery('%[1]s', :FulltextTerm))", s.pgDefaultTextSearchConfig, convertMySQLFullTextColumnsToPostgres(searchColumns)) - - return -} - -func (s SqlChannelStore) buildFulltextClauseX(term string, searchColumns ...string) sq.Sqlizer { - // Copy the terms as we will need to prepare them differently for each search type. - fulltextTerm := term - - // These chars must be treated as spaces in the fulltext query. - fulltextTerm = strings.Map(func(r rune) rune { - if strings.ContainsRune(spaceFulltextSearchChars, r) { - return ' ' - } - return r - }, fulltextTerm) - - // Prepare the FULLTEXT portion of the query. - if s.DriverName() == model.DatabaseDriverPostgres { - // remove all pipes | - fulltextTerm = strings.ReplaceAll(fulltextTerm, "|", "") - - // split the search term and append :* to each part - splitTerm := strings.Fields(fulltextTerm) - for i, t := range splitTerm { - splitTerm[i] = t + ":*" - } - - // join the search term with & - fulltextTerm = strings.Join(splitTerm, " & ") - - expr := fmt.Sprintf("((to_tsvector('%[1]s', %[2]s)) @@ to_tsquery('%[1]s', ?))", s.pgDefaultTextSearchConfig, strings.Join(searchColumns, " || ' ' || ")) - return sq.Expr(expr, fulltextTerm) - } - - splitTerm := strings.Fields(fulltextTerm) - for i, t := range splitTerm { - splitTerm[i] = "+" + t + "*" - } - - fulltextTerm = strings.Join(splitTerm, " ") - - expr := fmt.Sprintf("MATCH(%s) AGAINST (? IN BOOLEAN MODE)", strings.Join(searchColumns, ", ")) + expr := fmt.Sprintf("((to_tsvector('%[1]s', %[2]s)) @@ to_tsquery('%[1]s', ?))", s.pgDefaultTextSearchConfig, strings.Join(searchColumns, " || ' ' || ")) return sq.Expr(expr, fulltextTerm) } @@ -3684,57 +3568,19 @@ func (s SqlChannelStore) searchClause(term string) sq.Sqlizer { return nil } - fulltextClause := s.buildFulltextClauseX(term, "c.Name", "c.DisplayName", "c.Purpose") return sq.Or{ likeClause, - fulltextClause, + s.buildFulltextClause(term, "c.Name", "c.DisplayName", "c.Purpose"), } } -func (s SqlChannelStore) searchGroupChannelsQuery(userId, term string, isPostgreSQL bool) sq.SelectBuilder { - var baseLikeTerm string +func (s SqlChannelStore) searchGroupChannelsQuery(userId, term string) sq.SelectBuilder { + baseLikeTerm := "ARRAY_TO_STRING(ARRAY_AGG(u.Username), ', ') LIKE ?" terms := strings.Fields((strings.ToLower(term))) having := sq.And{} - if isPostgreSQL { - baseLikeTerm = "ARRAY_TO_STRING(ARRAY_AGG(u.Username), ', ') LIKE ?" - cc := s.getSubQueryBuilder().Select("c.Id"). - From("Channels c"). - Join("ChannelMembers cm ON c.Id=cm.ChannelId"). - Join("Users u on u.Id = cm.UserId"). - Where(sq.Eq{ - "c.Type": model.ChannelTypeGroup, - "u.id": userId, - }). - GroupBy("c.Id") - - for _, term := range terms { - term = sanitizeSearchTerm(term, "\\") - having = append(having, sq.Expr(baseLikeTerm, "%"+term+"%")) - } - - subq := s.getSubQueryBuilder().Select("cc.id"). - FromSelect(cc, "cc"). - Join("ChannelMembers cm On cc.Id = cm.ChannelId"). - Join("Users u On u.Id = cm.UserId"). - GroupBy("cc.Id"). - Having(having). - Limit(model.ChannelSearchDefaultLimit) - - return s.getQueryBuilder().Select(channelSliceColumns(true)...). - From("Channels"). - Where(sq.Expr("Id IN (?)", subq)) - } - - baseLikeTerm = "GROUP_CONCAT(u.Username SEPARATOR ', ') LIKE ?" - - for _, term := range terms { - term = sanitizeSearchTerm(term, "\\") - having = append(having, sq.Expr(baseLikeTerm, "%"+term+"%")) - } - - cc := s.getSubQueryBuilder().Select(channelSliceColumns(true, "c")...). + cc := s.getSubQueryBuilder().Select("c.Id"). From("Channels c"). Join("ChannelMembers cm ON c.Id=cm.ChannelId"). Join("Users u on u.Id = cm.UserId"). @@ -3744,18 +3590,26 @@ func (s SqlChannelStore) searchGroupChannelsQuery(userId, term string, isPostgre }). GroupBy("c.Id") - return s.getQueryBuilder().Select(channelSliceColumns(true, "cc")...). + for _, term := range terms { + term = sanitizeSearchTerm(term, "\\") + having = append(having, sq.Expr(baseLikeTerm, "%"+term+"%")) + } + + subq := s.getSubQueryBuilder().Select("cc.id"). FromSelect(cc, "cc"). - Join("ChannelMembers cm on cc.Id = cm.ChannelId"). - Join("Users u on u.Id = cm.UserId"). + Join("ChannelMembers cm On cc.Id = cm.ChannelId"). + Join("Users u On u.Id = cm.UserId"). GroupBy("cc.Id"). Having(having). Limit(model.ChannelSearchDefaultLimit) + + return s.getQueryBuilder().Select(channelSliceColumns(true)...). + From("Channels"). + Where(sq.Expr("Id IN (?)", subq)) } func (s SqlChannelStore) SearchGroupChannels(userId, term string) (model.ChannelList, error) { - isPostgreSQL := s.DriverName() == model.DatabaseDriverPostgres - query := s.searchGroupChannelsQuery(userId, term, isPostgreSQL) + query := s.searchGroupChannelsQuery(userId, term) sql, params, err := query.ToSql() if err != nil { @@ -4239,23 +4093,14 @@ func (s SqlChannelStore) UserBelongsToChannels(userId string, channelIds []strin // UpdateMembersRole updates all the members of channelID in the adminIDs string array to be admins and sets all other // users as not being admin. -// It returns the list of userIDs whose roles got updated. +// It returns the list of members whose roles got updated. // // TODO: parameterize adminIDs -func (s SqlChannelStore) UpdateMembersRole(channelID string, adminIDs []string) (_ []*model.ChannelMember, err error) { - transaction, err := s.GetMaster().Beginx() - if err != nil { - return nil, err - } - defer finalizeTransactionX(transaction, &err) - - // On MySQL it's not possible to update a table and select from it in the same query. - // A SELECT and a UPDATE query are needed. - // Once we only support PostgreSQL, this can be done in a single query using RETURNING. - query, args, err := s.getQueryBuilder(). - Select(channelMemberSliceColumns()...). - From("ChannelMembers"). - Where(sq.Eq{"ChannelID": channelID}). +func (s SqlChannelStore) UpdateMembersRole(channelID string, adminIDs []string) ([]*model.ChannelMember, error) { + query := s.getQueryBuilder(). + Update("ChannelMembers"). + Set("SchemeAdmin", sq.Case().When(sq.Eq{"UserId": adminIDs}, "true").Else("false")). + Where(sq.Eq{"ChannelId": channelID}). Where(sq.Or{sq.Eq{"SchemeGuest": false}, sq.Expr("SchemeGuest IS NULL")}). Where( sq.Or{ @@ -4270,42 +4115,14 @@ func (s SqlChannelStore) UpdateMembersRole(channelID string, adminIDs []string) sq.NotEq{"UserId": adminIDs}, }, }, - ).ToSql() - if err != nil { - return nil, errors.Wrap(err, "channel_tosql") - } + ). + Suffix("RETURNING " + strings.Join(channelMemberSliceColumns(), ", ")) var updatedMembers []*model.ChannelMember - if err = transaction.Select(&updatedMembers, query, args...); err != nil { - return nil, errors.Wrap(err, "failed to get list of updated users") - } - - // Update SchemeAdmin field as the data from the SQL is not updated yet - for _, member := range updatedMembers { - if slices.Contains(adminIDs, member.UserId) { - member.SchemeAdmin = true - } else { - member.SchemeAdmin = false - } - } - - query, args, err = s.getQueryBuilder(). - Update("ChannelMembers"). - Set("SchemeAdmin", sq.Case().When(sq.Eq{"UserId": adminIDs}, "true").Else("false")). - Where(sq.Eq{"ChannelId": channelID}). - Where(sq.Or{sq.Eq{"SchemeGuest": false}, sq.Expr("SchemeGuest IS NULL")}).ToSql() - if err != nil { - return nil, errors.Wrap(err, "team_tosql") - } - - if _, err = transaction.Exec(query, args...); err != nil { + if err := s.GetMaster().SelectBuilder(&updatedMembers, query); err != nil { return nil, errors.Wrap(err, "failed to update ChannelMembers") } - if err = transaction.Commit(); err != nil { - return nil, errors.Wrap(err, "commit_transaction") - } - return updatedMembers, nil } diff --git a/server/channels/store/sqlstore/draft_store.go b/server/channels/store/sqlstore/draft_store.go index 64c72070e49..8b7e57fe5f6 100644 --- a/server/channels/store/sqlstore/draft_store.go +++ b/server/channels/store/sqlstore/draft_store.go @@ -229,22 +229,18 @@ func (s *SqlDraftStore) GetMaxDraftSize() int { func (s *SqlDraftStore) determineMaxDraftSize() int { var maxDraftSizeBytes int32 - if s.DriverName() == model.DatabaseDriverPostgres { - // The Draft.Message column in Postgres has historically been VARCHAR(4000), but - // may be manually enlarged to support longer drafts. - if err := s.GetReplica().Get(&maxDraftSizeBytes, ` - SELECT - COALESCE(character_maximum_length, 0) - FROM - information_schema.columns - WHERE - table_name = 'drafts' - AND column_name = 'message' - `); err != nil { - mlog.Warn("Unable to determine the maximum supported draft size", mlog.Err(err)) - } - } else { - mlog.Warn("No implementation found to determine the maximum supported draft size") + // The Draft.Message column has historically been VARCHAR(4000), but + // may be manually enlarged to support longer drafts. + if err := s.GetReplica().Get(&maxDraftSizeBytes, ` + SELECT + COALESCE(character_maximum_length, 0) + FROM + information_schema.columns + WHERE + table_name = 'drafts' + AND column_name = 'message' + `); err != nil { + mlog.Warn("Unable to determine the maximum supported draft size", mlog.Err(err)) } // Assume a worst-case representation of four bytes per rune. @@ -288,31 +284,28 @@ func (s *SqlDraftStore) GetLastCreateAtAndUserIdValuesForEmptyDraftsMigration(cr } func (s *SqlDraftStore) DeleteEmptyDraftsByCreateAtAndUserId(createAt int64, userId string) error { - var builder Builder - if s.DriverName() == model.DatabaseDriverPostgres { - builder = s.getQueryBuilder(). - Delete("Drafts d"). - PrefixExpr(s.getQueryBuilder().Select(). - Prefix("WITH dd AS ("). - Columns("UserId", "ChannelId", "RootId"). - From("Drafts"). - Where(sq.Or{ - sq.Gt{"CreateAt": createAt}, - sq.And{ - sq.Eq{"CreateAt": createAt}, - sq.Gt{"UserId": userId}, - }, - }). - OrderBy("CreateAt", "UserId"). - Limit(100). - Suffix(")"), - ). - Using("dd"). - Where("d.UserId = dd.UserId"). - Where("d.ChannelId = dd.ChannelId"). - Where("d.RootId = dd.RootId"). - Where("d.Message = ''") - } + builder := s.getQueryBuilder(). + Delete("Drafts d"). + PrefixExpr(s.getQueryBuilder().Select(). + Prefix("WITH dd AS ("). + Columns("UserId", "ChannelId", "RootId"). + From("Drafts"). + Where(sq.Or{ + sq.Gt{"CreateAt": createAt}, + sq.And{ + sq.Eq{"CreateAt": createAt}, + sq.Gt{"UserId": userId}, + }, + }). + OrderBy("CreateAt", "UserId"). + Limit(100). + Suffix(")"), + ). + Using("dd"). + Where("d.UserId = dd.UserId"). + Where("d.ChannelId = dd.ChannelId"). + Where("d.RootId = dd.RootId"). + Where("d.Message = ''") if _, err := s.GetMaster().ExecBuilder(builder); err != nil { return errors.Wrapf(err, "failed to delete empty drafts") @@ -322,31 +315,28 @@ func (s *SqlDraftStore) DeleteEmptyDraftsByCreateAtAndUserId(createAt int64, use } func (s *SqlDraftStore) DeleteOrphanDraftsByCreateAtAndUserId(createAt int64, userId string) error { - var builder Builder - if s.DriverName() == model.DatabaseDriverPostgres { - builder = s.getQueryBuilder(). - Delete("Drafts d"). - PrefixExpr(s.getQueryBuilder().Select(). - Prefix("WITH dd AS ("). - Columns("UserId", "ChannelId", "RootId"). - From("Drafts"). - Where(sq.Or{ - sq.Gt{"CreateAt": createAt}, - sq.And{ - sq.Eq{"CreateAt": createAt}, - sq.Gt{"UserId": userId}, - }, - }). - OrderBy("CreateAt", "UserId"). - Limit(100). - Suffix(")"), - ). - Using("dd"). - Where("d.UserId = dd.UserId"). - Where("d.ChannelId = dd.ChannelId"). - Where("d.RootId = dd.RootId"). - Suffix("AND (d.RootId IN (SELECT Id FROM Posts WHERE DeleteAt <> 0) OR NOT EXISTS (SELECT 1 FROM Posts WHERE Posts.Id = d.RootId))") - } + builder := s.getQueryBuilder(). + Delete("Drafts d"). + PrefixExpr(s.getQueryBuilder().Select(). + Prefix("WITH dd AS ("). + Columns("UserId", "ChannelId", "RootId"). + From("Drafts"). + Where(sq.Or{ + sq.Gt{"CreateAt": createAt}, + sq.And{ + sq.Eq{"CreateAt": createAt}, + sq.Gt{"UserId": userId}, + }, + }). + OrderBy("CreateAt", "UserId"). + Limit(100). + Suffix(")"), + ). + Using("dd"). + Where("d.UserId = dd.UserId"). + Where("d.ChannelId = dd.ChannelId"). + Where("d.RootId = dd.RootId"). + Suffix("AND (d.RootId IN (SELECT Id FROM Posts WHERE DeleteAt <> 0) OR NOT EXISTS (SELECT 1 FROM Posts WHERE Posts.Id = d.RootId))") if _, err := s.GetMaster().ExecBuilder(builder); err != nil { return errors.Wrapf(err, "failed to delete orphan drafts") diff --git a/server/channels/store/sqlstore/file_info_store.go b/server/channels/store/sqlstore/file_info_store.go index ed0703b7a8f..753a6b38317 100644 --- a/server/channels/store/sqlstore/file_info_store.go +++ b/server/channels/store/sqlstore/file_info_store.go @@ -417,7 +417,6 @@ func (fs SqlFileInfoStore) AttachToPost(rctx request.CTX, fileId, postId, channe count, err := sqlResult.RowsAffected() if err != nil { - // RowsAffected should never fail with the MySQL or Postgres drivers return errors.Wrap(err, "unable to retrieve rows affected") } else if count == 0 { // Could not attach the file to the post @@ -494,12 +493,7 @@ func (fs SqlFileInfoStore) PermanentDelete(rctx request.CTX, fileId string) erro } func (fs SqlFileInfoStore) PermanentDeleteBatch(rctx request.CTX, endTime int64, limit int64) (int64, error) { - var query string - if fs.DriverName() == "postgres" { - query = "DELETE from FileInfo WHERE Id = any (array (SELECT Id FROM FileInfo WHERE CreateAt < ? AND CreatorId != ? LIMIT ?))" - } else { - query = "DELETE from FileInfo WHERE CreateAt < ? AND CreatorId != ? LIMIT ?" - } + query := "DELETE from FileInfo WHERE Id = any (array (SELECT Id FROM FileInfo WHERE CreateAt < ? AND CreatorId != ? LIMIT ?))" sqlResult, err := fs.GetMaster().Exec(query, endTime, model.BookmarkFileOwner, limit) if err != nil { @@ -625,14 +619,14 @@ func (fs SqlFileInfoStore) Search(rctx request.CTX, paramsList []*model.SearchPa terms := params.Terms excludedTerms := params.ExcludedTerms - for _, c := range fs.specialSearchChars() { + for _, c := range specialSearchChars { terms = strings.Replace(terms, c, " ", -1) excludedTerms = strings.Replace(excludedTerms, c, " ", -1) } if terms == "" && excludedTerms == "" { // we've already confirmed that we have a channel or user to search for - } else if fs.DriverName() == model.DatabaseDriverPostgres { + } else { // Parse text for wildcards if wildcard, err := regexp.Compile(`\*($| )`); err == nil { terms = wildcard.ReplaceAllLiteralString(terms, ":* ") @@ -683,17 +677,9 @@ func (fs SqlFileInfoStore) Search(rctx request.CTX, paramsList []*model.SearchPa } func (fs SqlFileInfoStore) CountAll() (int64, error) { - var query sq.SelectBuilder - if fs.DriverName() == model.DatabaseDriverPostgres { - query = fs.getQueryBuilder(). - Select("num"). - From("file_stats") - } else { - query = fs.getQueryBuilder(). - Select("COUNT(*)"). - From("FileInfo"). - Where("DeleteAt = 0") - } + query := fs.getQueryBuilder(). + Select("num"). + From("file_stats") var count int64 err := fs.GetReplica().GetBuilder(&count, query) @@ -733,7 +719,7 @@ func (fs SqlFileInfoStore) GetFilesBatchForIndexing(startTime int64, startFileID func (fs SqlFileInfoStore) GetStorageUsage(_, includeDeleted bool) (int64, error) { var query sq.SelectBuilder - if fs.DriverName() == model.DatabaseDriverPostgres && !includeDeleted { + if !includeDeleted { query = fs.getQueryBuilder(). Select("usage"). From("file_stats") @@ -741,10 +727,6 @@ func (fs SqlFileInfoStore) GetStorageUsage(_, includeDeleted bool) (int64, error query = fs.getQueryBuilder(). Select("COALESCE(SUM(Size), 0)"). From("FileInfo") - - if !includeDeleted { - query = query.Where("DeleteAt = 0") - } } var size int64 @@ -812,15 +794,13 @@ func (fs SqlFileInfoStore) RestoreForPostByIds(rctx request.CTX, postId string, } func (fs SqlFileInfoStore) RefreshFileStats() error { - if fs.DriverName() == model.DatabaseDriverPostgres { - // CONCURRENTLY is not used deliberately because as per Postgres docs, - // not using CONCURRENTLY takes less resources and completes faster - // at the expense of locking the mat view. Since viewing admin console - // is not a very frequent activity, we accept the tradeoff to let the - // refresh happen as fast as possible. - if _, err := fs.GetMaster().Exec("REFRESH MATERIALIZED VIEW file_stats"); err != nil { - return errors.Wrap(err, "error refreshing materialized view file_stats") - } + // CONCURRENTLY is not used deliberately because as per Postgres docs, + // not using CONCURRENTLY takes less resources and completes faster + // at the expense of locking the mat view. Since viewing admin console + // is not a very frequent activity, we accept the tradeoff to let the + // refresh happen as fast as possible. + if _, err := fs.GetMaster().Exec("REFRESH MATERIALIZED VIEW file_stats"); err != nil { + return errors.Wrap(err, "error refreshing materialized view file_stats") } return nil diff --git a/server/channels/store/sqlstore/job_store.go b/server/channels/store/sqlstore/job_store.go index 5db507bbbc5..cb39c58d13d 100644 --- a/server/channels/store/sqlstore/job_store.go +++ b/server/channels/store/sqlstore/job_store.go @@ -434,12 +434,7 @@ func (jss SqlJobStore) Delete(id string) (string, error) { } func (jss SqlJobStore) Cleanup(expiryTime int64, batchSize int) error { - var query string - if jss.DriverName() == model.DatabaseDriverPostgres { - query = "DELETE FROM Jobs WHERE Id IN (SELECT Id FROM Jobs WHERE CreateAt < ? AND (Status != ? AND Status != ?) ORDER BY CreateAt ASC LIMIT ?)" - } else { - query = "DELETE FROM Jobs WHERE CreateAt < ? AND (Status != ? AND Status != ?) ORDER BY CreateAt ASC LIMIT ?" - } + query := "DELETE FROM Jobs WHERE Id IN (SELECT Id FROM Jobs WHERE CreateAt < ? AND (Status != ? AND Status != ?) ORDER BY CreateAt ASC LIMIT ?)" var rowsAffected int64 = 1 diff --git a/server/channels/store/sqlstore/migrate.go b/server/channels/store/sqlstore/migrate.go index aabd969ef7b..9a474e58f0e 100644 --- a/server/channels/store/sqlstore/migrate.go +++ b/server/channels/store/sqlstore/migrate.go @@ -17,7 +17,6 @@ import ( "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/v8/channels/db" "github.com/mattermost/morph" - "github.com/mattermost/morph/drivers" ps "github.com/mattermost/morph/drivers/postgres" "github.com/mattermost/morph/models" mbindata "github.com/mattermost/morph/sources/embedded" @@ -111,13 +110,7 @@ func (ss *SqlStore) initMorph(dryRun, enableLogging bool) (*morph.Morph, error) return nil, err } - var driver drivers.Driver - switch ss.DriverName() { - case model.DatabaseDriverPostgres: - driver, err = ps.WithInstance(ss.GetMaster().DB.DB) - default: - err = fmt.Errorf("unsupported database type %s for migration", ss.DriverName()) - } + driver, err := ps.WithInstance(ss.GetMaster().DB.DB) if err != nil { return nil, err } diff --git a/server/channels/store/sqlstore/post_store.go b/server/channels/store/sqlstore/post_store.go index 60cbbeec336..984a7d3c249 100644 --- a/server/channels/store/sqlstore/post_store.go +++ b/server/channels/store/sqlstore/post_store.go @@ -25,14 +25,19 @@ import ( ) // Regex to get quoted strings -var quotedStringsRegex = regexp.MustCompile(`("[^"]*")`) -var wildCardRegex = regexp.MustCompile(`\*($| )`) +var ( + quotedStringsRegex = regexp.MustCompile(`("[^"]*")`) + wildCardRegex = regexp.MustCompile(`\*($| )`) +) type SqlPostStore struct { *SqlStore metrics einterfaces.MetricsInterface maxPostSizeOnce sync.Once maxPostSizeCached int + + // postsQuery is a starting point for queries that return one or more Posts. + postsQuery sq.SelectBuilder } type postWithExtra struct { @@ -138,11 +143,17 @@ func postSliceCoalesceQuery() string { } func newSqlPostStore(sqlStore *SqlStore, metrics einterfaces.MetricsInterface) store.PostStore { - return &SqlPostStore{ + s := &SqlPostStore{ SqlStore: sqlStore, metrics: metrics, maxPostSizeCached: model.PostMessageMaxRunesV1, } + + s.postsQuery = s.getQueryBuilder(). + Select(postSliceColumnsWithName("Posts")...). + From("Posts") + + return s } func (s *SqlPostStore) SaveMultiple(rctx request.CTX, posts []*model.Post) ([]*model.Post, int, error) { @@ -238,10 +249,6 @@ func (s *SqlPostStore) SaveMultiple(rctx request.CTX, posts []*model.Post) ([]*m for _, post := range posts { builder = builder.Values(postToSlice(post)...) } - query, args, err := builder.ToSql() - if err != nil { - return nil, -1, errors.Wrap(err, "post_tosql") - } transaction, err := s.GetMaster().Beginx() if err != nil { @@ -249,7 +256,7 @@ func (s *SqlPostStore) SaveMultiple(rctx request.CTX, posts []*model.Post) ([]*m } defer finalizeTransactionX(transaction, &err) - if _, err = transaction.Exec(query, args...); err != nil { + if _, err = transaction.ExecBuilder(builder); err != nil { return nil, -1, errors.Wrap(err, "failed to save Post") } @@ -353,12 +360,7 @@ func (s *SqlPostStore) populateReplyCount(posts []*model.Post) error { Where(sq.Eq{"Posts.DeleteAt": 0}). GroupBy("RootId") - queryString, args, err := query.ToSql() - if err != nil { - return errors.Wrap(err, "post_tosql") - } - err = s.GetMaster().Select(&countList, queryString, args...) - if err != nil { + if err := s.GetMaster().SelectBuilder(&countList, query); err != nil { return errors.Wrap(err, "failed to count Posts") } @@ -435,12 +437,7 @@ func (s *SqlPostStore) Update(rctx request.CTX, newPost *model.Post, oldPost *mo Insert("Posts"). Columns(postSliceColumns()...). Values(postToSlice(oldPost)...) - query, args, err := builder.ToSql() - if err != nil { - return nil, errors.Wrap(err, "post_tosql") - } - _, err = s.GetMaster().Exec(query, args...) - if err != nil { + if _, err := s.GetMaster().ExecBuilder(builder); err != nil { return nil, errors.Wrap(err, "failed to insert the old post") } @@ -528,13 +525,16 @@ func (s *SqlPostStore) GetFlaggedPostsForChannel(userId, channelId string, offse func (s *SqlPostStore) getFlaggedPosts(userId, channelId, teamId string, offset int, limit int) (*model.PostList, error) { pl := model.NewPostList() + postColumnsA := strings.Join(postSliceColumnsWithName("A"), ", ") + postColumnsPosts := strings.Join(postSliceColumnsWithName("Posts"), ", ") + posts := []*model.Post{} query := ` SELECT - A.*, (SELECT count(*) FROM Posts WHERE Posts.RootId = (CASE WHEN A.RootId = '' THEN A.Id ELSE A.RootId END) AND Posts.DeleteAt = 0) as ReplyCount + ` + postColumnsA + `, (SELECT count(*) FROM Posts WHERE Posts.RootId = (CASE WHEN A.RootId = '' THEN A.Id ELSE A.RootId END) AND Posts.DeleteAt = 0) as ReplyCount FROM (SELECT - * + ` + postColumnsPosts + ` FROM Posts WHERE @@ -624,34 +624,26 @@ func (s *SqlPostStore) getPostWithCollapsedThreads(rctx request.CTX, id, userID ) var post postWithExtra - postFetchQuery, args, err := s.getQueryBuilder(). + query := s.getQueryBuilder(). Select(columns...). From("Posts"). LeftJoin("Threads ON Threads.PostId = Id"). LeftJoin("ThreadMemberships ON ThreadMemberships.PostId = Id AND ThreadMemberships.UserId = ?", userID). Where(sq.Eq{"Posts.DeleteAt": 0}). - Where(sq.Eq{"Posts.Id": id}).ToSql() - if err != nil { - return nil, errors.Wrap(err, "getPostWithCollapsedThreads_ToSql2") - } + Where(sq.Eq{"Posts.Id": id}) - err = s.GetReplica().Get(&post, postFetchQuery, args...) - if err != nil { + if err := s.GetReplica().GetBuilder(&post, query); err != nil { if err == sql.ErrNoRows { return nil, store.NewErrNotFound("Post", id) } - return nil, errors.Wrapf(err, "failed to get Post with id=%s", id) } posts := []*model.Post{} - query := s.getQueryBuilder(). - Select("*"). - From("Posts"). - Where(sq.Eq{ - "Posts.RootId": id, - "Posts.DeleteAt": 0, - }) + query = s.postsQuery.Where(sq.Eq{ + "Posts.RootId": id, + "Posts.DeleteAt": 0, + }) var sort string if opts.Direction != "" { @@ -713,12 +705,7 @@ func (s *SqlPostStore) getPostWithCollapsedThreads(rctx request.CTX, id, userID query = query.Limit(uint64(opts.PerPage + 1)) } - sql, args, err := query.ToSql() - if err != nil { - return nil, errors.Wrap(err, "getPostWithCollapsedThreads_Tosql2") - } - err = s.GetReplica().Select(&posts, sql, args...) - if err != nil { + if err := s.GetReplica().SelectBuilder(&posts, query); err != nil { return nil, errors.Wrapf(err, "failed to find Posts for thread %s", id) } @@ -756,7 +743,8 @@ func (s *SqlPostStore) Get(rctx request.CTX, id string, opts model.GetPostsOptio return nil, store.NewErrInvalidInput("Post", "id", id) } var post model.Post - postFetchQuery := "SELECT p.*, (SELECT count(*) FROM Posts WHERE Posts.RootId = (CASE WHEN p.RootId = '' THEN p.Id ELSE p.RootId END) AND Posts.DeleteAt = 0) as ReplyCount FROM Posts p WHERE p.Id = ? AND p.DeleteAt = 0" + postColumns := strings.Join(postSliceColumnsWithName("p"), ", ") + postFetchQuery := "SELECT " + postColumns + ", (SELECT count(*) FROM Posts WHERE Posts.RootId = (CASE WHEN p.RootId = '' THEN p.Id ELSE p.RootId END) AND Posts.DeleteAt = 0) as ReplyCount FROM Posts p WHERE p.Id = ? AND p.DeleteAt = 0" err := s.DBXFromContext(rctx.Context()).Get(&post, postFetchQuery, id) if err != nil { if err == sql.ErrNoRows { @@ -780,7 +768,8 @@ func (s *SqlPostStore) Get(rctx request.CTX, id string, opts model.GetPostsOptio var query sq.SelectBuilder query = s.getQueryBuilder(). - Select("p.*, replycount.num as ReplyCount"). + Select(postSliceColumnsWithName("p")...). + Column("replycount.num as ReplyCount"). PrefixExpr(s.getQueryBuilder(). Select(). Prefix("WITH replycount as ("). @@ -880,14 +869,8 @@ func (s *SqlPostStore) Get(rctx request.CTX, id string, opts model.GetPostsOptio query = query.Limit(uint64(opts.PerPage + 1)) } - sql, args, err := query.ToSql() - if err != nil { - return nil, errors.Wrap(err, "Get_Tosql") - } - posts := []*model.Post{} - err = s.GetReplica().Select(&posts, sql, args...) - if err != nil { + if err := s.GetReplica().SelectBuilder(&posts, query); err != nil { return nil, errors.Wrap(err, "failed to find Posts") } @@ -923,33 +906,23 @@ func (s *SqlPostStore) Get(rctx request.CTX, id string, opts model.GetPostsOptio } func (s *SqlPostStore) GetSingle(rctx request.CTX, id string, inclDeleted bool) (*model.Post, error) { - query := s.getQueryBuilder(). - Select("p.*"). - From("Posts p"). - Where(sq.Eq{"p.Id": id}) + query := s.postsQuery.Where(sq.Eq{"Posts.Id": id}) replyCountSubQuery := s.getQueryBuilder(). Select("COUNT(*)"). - From("Posts"). - Where(sq.Expr("Posts.RootId = (CASE WHEN p.RootId = '' THEN p.Id ELSE p.RootId END) AND Posts.DeleteAt = 0")) + From("Posts p"). + Where(sq.Expr("p.RootId = (CASE WHEN Posts.RootId = '' THEN Posts.Id ELSE Posts.RootId END) AND p.DeleteAt = 0")) if !inclDeleted { - query = query.Where(sq.Eq{"p.DeleteAt": 0}) + query = query.Where(sq.Eq{"Posts.DeleteAt": 0}) } query = query.Column(sq.Alias(replyCountSubQuery, "ReplyCount")) - queryString, args, err := query.ToSql() - if err != nil { - return nil, errors.Wrap(err, "getsingleincldeleted_tosql") - } - var post model.Post - err = s.DBXFromContext(rctx.Context()).Get(&post, queryString, args...) - if err != nil { + if err := s.DBXFromContext(rctx.Context()).GetBuilder(&post, query); err != nil { if err == sql.ErrNoRows { return nil, store.NewErrNotFound("Post", id) } - return nil, errors.Wrapf(err, "failed to get Post with id=%s", id) } return &post, nil @@ -1009,7 +982,6 @@ func (s *SqlPostStore) Delete(rctx request.CTX, postID string, time int64, delet UpdateAt = $1, Props = jsonb_set(Props, $2, $3) WHERE Id = $4 OR RootId = $4`, time, jsonKeyPath(model.PostPropsDeleteBy), jsonStringVal(deleteByID), postID) - if err != nil { return errors.Wrap(err, "failed to update Posts") } @@ -1313,7 +1285,7 @@ func (s *SqlPostStore) getPostsCollapsedThreads(rctx request.CTX, options model. var posts []*postWithExtra offset := options.PerPage * options.Page - postFetchQuery, args, _ := s.getQueryBuilder(). + query := s.getQueryBuilder(). Select(columns...). From("Posts"). LeftJoin("Threads ON Threads.PostId = Posts.Id"). @@ -1323,10 +1295,9 @@ func (s *SqlPostStore) getPostsCollapsedThreads(rctx request.CTX, options model. Where(sq.Eq{"Posts.RootId": ""}). Limit(uint64(options.PerPage)). Offset(uint64(offset)). - OrderBy("Posts.CreateAt DESC").ToSql() + OrderBy("Posts.CreateAt DESC") - err := s.GetReplica().Select(&posts, postFetchQuery, args...) - if err != nil { + if err := s.GetReplica().SelectBuilder(&posts, query); err != nil { return nil, errors.Wrapf(err, "failed to find Posts with channelId=%s", options.ChannelId) } @@ -1397,7 +1368,7 @@ func (s *SqlPostStore) getPostsSinceCollapsedThreads(rctx request.CTX, options m ) var posts []*postWithExtra - postFetchQuery, args, err := s.getQueryBuilder(). + query := s.getQueryBuilder(). Select(columns...). From("Posts"). LeftJoin("Threads ON Threads.PostId = Posts.Id"). @@ -1406,15 +1377,9 @@ func (s *SqlPostStore) getPostsSinceCollapsedThreads(rctx request.CTX, options m Where(sq.Gt{"Posts.UpdateAt": options.Time}). Where(sq.Eq{"Posts.RootId": ""}). OrderBy("Posts.CreateAt DESC"). - Limit(1000). - ToSql() + Limit(1000) - if err != nil { - return nil, errors.Wrapf(err, "getPostsSinceCollapsedThreads_ToSql") - } - - err = s.GetReplica().Select(&posts, postFetchQuery, args...) - if err != nil { + if err := s.GetReplica().SelectBuilder(&posts, query); err != nil { return nil, errors.Wrapf(err, "failed to find Posts with channelId=%s", options.ChannelId) } return s.prepareThreadedResponse(rctx, posts, options.CollapsedThreadsExtended, false, sanitizeOptions) @@ -1442,16 +1407,20 @@ func (s *SqlPostStore) GetPostsSince(rctx request.CTX, options model.GetPostsSin var query string var params []any + postColumnsPosts := strings.Join(postSliceColumnsWithName("Posts"), ", ") + postColumnsCte := strings.Join(postSliceColumnsWithName("cte"), ", ") + postColumnsP1 := strings.Join(postSliceColumnsWithName("p1"), ", ") + query = `WITH cte AS (SELECT - * + ` + postColumnsPosts + ` FROM Posts WHERE UpdateAt > ? AND ChannelId = ? LIMIT 1000) - (SELECT *` + replyCountQuery2 + ` FROM cte) + (SELECT ` + postColumnsCte + replyCountQuery2 + ` FROM cte) UNION - (SELECT *` + replyCountQuery1 + ` FROM Posts p1 WHERE id in (SELECT rootid FROM cte)) + (SELECT ` + postColumnsP1 + replyCountQuery1 + ` FROM Posts p1 WHERE id in (SELECT rootid FROM cte)) ORDER BY CreateAt ` + order params = []any{options.Time, options.ChannelId} @@ -1498,9 +1467,7 @@ func (s *SqlPostStore) HasAutoResponsePostByUserSince(options model.GetPostsSinc } func (s *SqlPostStore) GetPostsSinceForSync(options model.GetPostsSinceForSyncOptions, cursor model.GetPostsSinceForSyncCursor, limit int) ([]*model.Post, model.GetPostsSinceForSyncCursor, error) { - query := s.getQueryBuilder(). - Select("*"). - From("Posts"). + query := s.postsQuery. OrderBy("Posts.UpdateAt", "Id"). Limit(uint64(limit)) @@ -1513,8 +1480,10 @@ func (s *SqlPostStore) GetPostsSinceForSync(options model.GetPostsSinceForSyncOp }, }) } else { - query = query.Where(sq.Or{sq.Gt{"Posts.UpdateAt": cursor.LastPostUpdateAt}, - sq.And{sq.Eq{"Posts.UpdateAt": cursor.LastPostUpdateAt}, sq.Gt{"Posts.Id": cursor.LastPostUpdateID}}}) + query = query.Where(sq.Or{ + sq.Gt{"Posts.UpdateAt": cursor.LastPostUpdateAt}, + sq.And{sq.Eq{"Posts.UpdateAt": cursor.LastPostUpdateAt}, sq.Gt{"Posts.Id": cursor.LastPostUpdateID}}, + }) } if options.ChannelId != "" { @@ -1537,14 +1506,8 @@ func (s *SqlPostStore) GetPostsSinceForSync(options model.GetPostsSinceForSyncOp }}) } - queryString, args, err := query.ToSql() - if err != nil { - return nil, cursor, errors.Wrap(err, "getpostssinceforsync_tosql") - } - posts := []*model.Post{} - err = s.GetReplica().Select(&posts, queryString, args...) - if err != nil { + if err := s.GetReplica().SelectBuilder(&posts, query); err != nil { return nil, cursor, errors.Wrapf(err, "error getting Posts with channelId=%s", options.ChannelId) } @@ -1586,9 +1549,7 @@ func (s *SqlPostStore) GetPostsForReporting(rctx request.CTX, queryParams model. } // Build base query - request one extra to determine if there are more pages - query := s.getQueryBuilder(). - Select(postSliceColumns()...). - From("Posts"). + query := s.postsQuery. Where(sq.Eq{"ChannelId": queryParams.ChannelId}). OrderBy(fmt.Sprintf("%s %s", timeField, sortDirection), fmt.Sprintf("Id %s", sortDirection)). Limit(uint64(queryParams.PerPage + 1)) @@ -1671,9 +1632,7 @@ func (s *SqlPostStore) GetPostsForReporting(rctx request.CTX, queryParams model. } func (s *SqlPostStore) GetPostsByThread(threadId string, since int64) ([]*model.Post, error) { - query := s.getQueryBuilder(). - Select("*"). - From("Posts"). + query := s.postsQuery. Where(sq.Eq{"RootId": threadId}). Where(sq.Eq{"DeleteAt": 0}). Where(sq.GtOrEq{"CreateAt": since}) @@ -1709,7 +1668,7 @@ func (s *SqlPostStore) getPostsAround(rctx request.CTX, before bool, options mod direction = ">" sort = "ASC" } - columns := []string{"p.*"} + columns := postSliceColumnsWithName("p") if options.CollapsedThreads { columns = append(columns, "COALESCE(Threads.ReplyCount, 0) as ThreadReplyCount", @@ -1739,19 +1698,11 @@ func (s *SqlPostStore) getPostsAround(rctx request.CTX, before bool, options mod } query = query.From("Posts p"). Where(conditions). - // Adding ChannelId and DeleteAt order columns - // to let mysql choose the "idx_posts_channel_id_delete_at_create_at" index always. - // See MM-24170. - OrderBy("p.ChannelId", "p.DeleteAt", "p.CreateAt "+sort). + OrderBy("p.CreateAt " + sort). Limit(uint64(options.PerPage)). Offset(uint64(offset)) - queryString, args, err := query.ToSql() - if err != nil { - return nil, errors.Wrap(err, "post_tosql") - } - err = s.GetReplica().Select(&posts, queryString, args...) - if err != nil { + if err := s.GetReplica().SelectBuilder(&posts, query); err != nil { return nil, errors.Wrapf(err, "failed to find Posts with channelId=%s", options.ChannelId) } @@ -1763,7 +1714,7 @@ func (s *SqlPostStore) getPostsAround(rctx request.CTX, before bool, options mod rootIds = append(rootIds, post.RootId) } } - rootQuery := s.getQueryBuilder().Select("p.*") + rootQuery := s.getQueryBuilder().Select(postSliceColumnsWithName("p")...) idQuery := sq.Or{ sq.Eq{"Id": rootIds}, } @@ -1783,14 +1734,8 @@ func (s *SqlPostStore) getPostsAround(rctx request.CTX, before bool, options mod rootQuery = rootQuery.Where(sq.Eq{"p.DeleteAt": 0}) } - rootQueryString, rootArgs, nErr := rootQuery.ToSql() - - if nErr != nil { - return nil, errors.Wrap(nErr, "post_tosql") - } - nErr = s.GetReplica().Select(&parents, rootQueryString, rootArgs...) - if nErr != nil { - return nil, errors.Wrapf(nErr, "failed to find Posts with channelId=%s", options.ChannelId) + if err := s.GetReplica().SelectBuilder(&parents, rootQuery); err != nil { + return nil, errors.Wrapf(err, "failed to find Posts with channelId=%s", options.ChannelId) } } @@ -1837,19 +1782,11 @@ func (s *SqlPostStore) getPostIdAroundTime(channelId string, time int64, before Select("Id"). From("Posts"). Where(conditions). - // Adding ChannelId and DeleteAt order columns - // to let mysql choose the "idx_posts_channel_id_delete_at_create_at" index always. - // See MM-23369. - OrderBy("Posts.ChannelId", "Posts.DeleteAt", "Posts.CreateAt "+sort). + OrderBy("Posts.CreateAt " + sort). Limit(1) - queryString, args, err := query.ToSql() - if err != nil { - return "", errors.Wrap(err, "post_tosql") - } - var postId string - if err := s.GetMaster().Get(&postId, queryString, args...); err != nil { + if err := s.GetMaster().GetBuilder(&postId, query); err != nil { if err != sql.ErrNoRows { return "", errors.Wrapf(err, "failed to get Post id with channelId=%s", channelId) } @@ -1867,23 +1804,13 @@ func (s *SqlPostStore) GetPostAfterTime(channelId string, time int64, collapsedT if collapsedThreads { conditions = sq.And{conditions, sq.Eq{"RootId": ""}} } - query := s.getQueryBuilder(). - Select("*"). - From("Posts"). + query := s.postsQuery. Where(conditions). - // Adding ChannelId and DeleteAt order columns - // to let mysql choose the "idx_posts_channel_id_delete_at_create_at" index always. - // See MM-23369. - OrderBy("Posts.ChannelId", "Posts.DeleteAt", "Posts.CreateAt ASC"). + OrderBy("Posts.CreateAt ASC"). Limit(1) - queryString, args, err := query.ToSql() - if err != nil { - return nil, errors.Wrap(err, "post_tosql") - } - var post model.Post - if err := s.GetMaster().Get(&post, queryString, args...); err != nil { + if err := s.GetMaster().GetBuilder(&post, query); err != nil { if err != sql.ErrNoRows { return nil, errors.Wrapf(err, "failed to get Post with channelId=%s", channelId) } @@ -1895,15 +1822,17 @@ func (s *SqlPostStore) GetPostAfterTime(channelId string, time int64, collapsedT func (s *SqlPostStore) getRootPosts(channelId string, offset int, limit int, skipFetchThreads bool, includeDeleted bool) ([]*model.Post, error) { posts := []*model.Post{} var fetchQuery string + postColumnsP := strings.Join(postSliceColumnsWithName("p"), ", ") + postColumnsPosts := strings.Join(postSliceColumnsWithName("Posts"), ", ") if skipFetchThreads { - fetchQuery = "SELECT p.*, (SELECT COUNT(*) FROM Posts WHERE Posts.RootId = (CASE WHEN p.RootId = '' THEN p.Id ELSE p.RootId END)) as ReplyCount FROM Posts p WHERE p.ChannelId = ? ORDER BY p.CreateAt DESC LIMIT ? OFFSET ?" + fetchQuery = "SELECT " + postColumnsP + ", (SELECT COUNT(*) FROM Posts WHERE Posts.RootId = (CASE WHEN p.RootId = '' THEN p.Id ELSE p.RootId END)) as ReplyCount FROM Posts p WHERE p.ChannelId = ? ORDER BY p.CreateAt DESC LIMIT ? OFFSET ?" if !includeDeleted { - fetchQuery = "SELECT p.*, (SELECT COUNT(*) FROM Posts WHERE Posts.RootId = (CASE WHEN p.RootId = '' THEN p.Id ELSE p.RootId END) AND Posts.DeleteAt = 0) as ReplyCount FROM Posts p WHERE p.ChannelId = ? AND p.DeleteAt = 0 ORDER BY p.CreateAt DESC LIMIT ? OFFSET ?" + fetchQuery = "SELECT " + postColumnsP + ", (SELECT COUNT(*) FROM Posts WHERE Posts.RootId = (CASE WHEN p.RootId = '' THEN p.Id ELSE p.RootId END) AND Posts.DeleteAt = 0) as ReplyCount FROM Posts p WHERE p.ChannelId = ? AND p.DeleteAt = 0 ORDER BY p.CreateAt DESC LIMIT ? OFFSET ?" } } else { - fetchQuery = "SELECT * FROM Posts WHERE Posts.ChannelId = ? ORDER BY Posts.CreateAt DESC LIMIT ? OFFSET ?" + fetchQuery = "SELECT " + postColumnsPosts + " FROM Posts WHERE Posts.ChannelId = ? ORDER BY Posts.CreateAt DESC LIMIT ? OFFSET ?" if !includeDeleted { - fetchQuery = "SELECT * FROM Posts WHERE Posts.ChannelId = ? AND Posts.DeleteAt = 0 ORDER BY Posts.CreateAt DESC LIMIT ? OFFSET ?" + fetchQuery = "SELECT " + postColumnsPosts + " FROM Posts WHERE Posts.ChannelId = ? AND Posts.DeleteAt = 0 ORDER BY Posts.CreateAt DESC LIMIT ? OFFSET ?" } } @@ -1915,82 +1844,6 @@ func (s *SqlPostStore) getRootPosts(channelId string, offset int, limit int, ski } func (s *SqlPostStore) getParentsPosts(channelId string, offset int, limit int, skipFetchThreads bool, includeDeleted bool) ([]*model.Post, error) { - if s.DriverName() == model.DatabaseDriverPostgres { - return s.getParentsPostsPostgreSQL(channelId, offset, limit, skipFetchThreads, includeDeleted) - } - - deleteAtCondition := "AND DeleteAt = 0" - if includeDeleted { - deleteAtCondition = "" - } - - // query parent Ids first - roots := []string{} - rootQuery := ` - SELECT DISTINCT - q.RootId - FROM - (SELECT - Posts.RootId - FROM - Posts - WHERE - ChannelId = ? ` + deleteAtCondition + ` - ORDER BY CreateAt DESC - LIMIT ? OFFSET ?) q - WHERE q.RootId != ''` - - err := s.GetReplica().Select(&roots, rootQuery, channelId, limit, offset) - if err != nil { - return nil, errors.Wrap(err, "failed to find Posts") - } - if len(roots) == 0 { - return nil, nil - } - - cols := []string{"p.*"} - var where sq.Sqlizer - where = sq.Eq{"p.Id": roots} - if skipFetchThreads { - col := "(SELECT COUNT(*) FROM Posts WHERE Posts.RootId = (CASE WHEN p.RootId = '' THEN p.Id ELSE p.RootId END)) as ReplyCount" - if !includeDeleted { - col = "(SELECT COUNT(*) FROM Posts WHERE Posts.RootId = (CASE WHEN p.RootId = '' THEN p.Id ELSE p.RootId END) AND Posts.DeleteAt = 0) as ReplyCount" - } - cols = append(cols, col) - } else { - where = sq.Or{ - where, - sq.Eq{"p.RootId": roots}, - } - } - - query := s.getQueryBuilder(). - Select(cols...). - From("Posts p"). - Where(sq.And{ - where, - sq.Eq{"p.ChannelId": channelId}, - }). - OrderBy("p.CreateAt") - - if !includeDeleted { - query = query.Where(sq.Eq{"p.DeleteAt": 0}) - } - - sql, args, err := query.ToSql() - if err != nil { - return nil, errors.Wrap(err, "ParentPosts_Tosql") - } - - posts := []*model.Post{} - err = s.GetReplica().Select(&posts, sql, args...) - if err != nil { - return nil, errors.Wrap(err, "failed to find Posts") - } - return posts, nil -} - -func (s *SqlPostStore) getParentsPostsPostgreSQL(channelId string, offset int, limit int, skipFetchThreads bool, includeDeleted bool) ([]*model.Post, error) { posts := []*model.Post{} replyCountQuery := "" onStatement := "q1.RootId = q2.Id" @@ -2009,8 +1862,10 @@ func (s *SqlPostStore) getParentsPostsPostgreSQL(channelId string, offset int, l deleteAtQueryCondition, deleteAtSubQueryCondition = "", "" } + postColumnsQ2 := strings.Join(postSliceColumnsWithName("q2"), ", ") + err := s.GetReplica().Select(&posts, - `SELECT q2.*`+replyCountQuery+` + `SELECT `+postColumnsQ2+replyCountQuery+` FROM Posts q2 INNER JOIN @@ -2042,7 +1897,7 @@ func (s *SqlPostStore) GetNthRecentPostTime(n int64) (int64, error) { return 0, errors.New("n can't be less than 1") } - builder := s.getQueryBuilder(). + query := s.getQueryBuilder(). Select("CreateAt"). From("Posts p"). // Consider users posts only for cloud limit @@ -2054,17 +1909,11 @@ func (s *SqlPostStore) GetNthRecentPostTime(n int64) (int64, error) { Limit(1). Offset(uint64(n - 1)) - query, queryArgs, err := builder.ToSql() - if err != nil { - return 0, errors.Wrap(err, "GetNthRecentPostTime_tosql") - } - var createAt int64 - if err := s.GetMaster().Get(&createAt, query, queryArgs...); err != nil { + if err := s.GetMaster().GetBuilder(&createAt, query); err != nil { if err == sql.ErrNoRows { return 0, store.NewErrNotFound("Post", "none") } - return 0, errors.Wrapf(err, "failed to get the Nth Post=%d", n) } @@ -2203,7 +2052,8 @@ func (s *SqlPostStore) search(teamId string, userId string, params *model.Search } baseQuery := s.getQueryBuilder().Select( - "*", + postSliceColumnsWithName("q2")..., + ).Column( "(SELECT COUNT(*) FROM Posts WHERE Posts.RootId = (CASE WHEN q2.RootId = '' THEN q2.Id ELSE q2.RootId END) AND Posts.DeleteAt = 0) as ReplyCount", ).From("Posts q2"). Where("q2.DeleteAt = 0"). @@ -2230,7 +2080,7 @@ func (s *SqlPostStore) search(teamId string, userId string, params *model.Search } } - for _, c := range s.specialSearchChars() { + for _, c := range specialSearchChars { if !params.IsHashtag { terms = strings.Replace(terms, c, " ", -1) } @@ -2312,14 +2162,9 @@ func (s *SqlPostStore) search(teamId string, userId string, params *model.Search baseQuery = baseQuery.Where(fmt.Sprintf("ChannelId IN (%s)", inQueryClause), inQueryClauseArgs...) - searchQuery, searchQueryArgs, err := baseQuery.ToSql() - if err != nil { - return nil, err - } - var posts []*model.Post - if err := s.GetSearchReplicaX().Select(&posts, searchQuery, searchQueryArgs...); err != nil { + if err := s.GetSearchReplicaX().SelectBuilder(&posts, baseQuery); err != nil { mlog.Warn("Query error searching posts.", mlog.String("error", trimInput(err.Error()))) // Don't return the error to the caller as it is of no use to the user. Instead return an empty set of search results. } else { @@ -2352,9 +2197,8 @@ func (s *SqlPostStore) search(teamId string, userId string, params *model.Search func (s *SqlPostStore) AnalyticsUserCountsWithPostsByDay(teamId string) (model.AnalyticsRows, error) { var args []any query := - `SELECT DISTINCT - DATE(FROM_UNIXTIME(Posts.CreateAt / 1000)) AS Name, - COUNT(DISTINCT Posts.UserId) AS Value + `SELECT + TO_CHAR(DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)), 'YYYY-MM-DD') AS Name, COUNT(DISTINCT Posts.UserId) AS Value FROM Posts` if teamId != "" { @@ -2365,29 +2209,10 @@ func (s *SqlPostStore) AnalyticsUserCountsWithPostsByDay(teamId string) (model.A } query += ` Posts.CreateAt >= ? AND Posts.CreateAt <= ? - GROUP BY DATE(FROM_UNIXTIME(Posts.CreateAt / 1000)) + GROUP BY DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)) ORDER BY Name DESC LIMIT 30` - if s.DriverName() == model.DatabaseDriverPostgres { - query = - `SELECT - TO_CHAR(DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)), 'YYYY-MM-DD') AS Name, COUNT(DISTINCT Posts.UserId) AS Value - FROM Posts` - - if teamId != "" { - query += " INNER JOIN Channels ON Posts.ChannelId = Channels.Id AND Channels.TeamId = ? AND" - args = []any{teamId} - } else { - query += " WHERE" - } - - query += ` Posts.CreateAt >= ? AND Posts.CreateAt <= ? - GROUP BY DATE(TO_TIMESTAMP(Posts.CreateAt / 1000)) - ORDER BY Name DESC - LIMIT 30` - } - end := utils.MillisFromTime(utils.EndOfDay(utils.Yesterday())) start := utils.MillisFromTime(utils.StartOfDay(utils.Yesterday().AddDate(0, 0, -31))) args = append(args, start, end) @@ -2463,56 +2288,16 @@ func (s *SqlPostStore) countPostsByDay(teamID, startDay, endDay string) (model.A // TODO: convert to squirrel HW func (s *SqlPostStore) AnalyticsPostCountsByDay(options *model.AnalyticsPostCountsOptions) (model.AnalyticsRows, error) { - if s.DriverName() == model.DatabaseDriverPostgres { - endDay := utils.Yesterday().Format("2006-01-02") - startDay := utils.Yesterday().AddDate(0, 0, -31).Format("2006-01-02") - if options.YesterdayOnly { - startDay = utils.Yesterday().AddDate(0, 0, -1).Format("2006-01-02") - } - // Use materialized views - if options.BotsOnly { - return s.countBotPostsByDay(options.TeamId, startDay, endDay) - } - return s.countPostsByDay(options.TeamId, startDay, endDay) - } - - var args []any - query := - `SELECT - DATE(FROM_UNIXTIME(Posts.CreateAt / 1000)) AS Name, - COUNT(Posts.Id) AS Value - FROM Posts` - - if options.BotsOnly { - query += " INNER JOIN Bots ON Posts.UserId = Bots.Userid" - } - - if options.TeamId != "" { - query += " INNER JOIN Channels ON Posts.ChannelId = Channels.Id AND Channels.TeamId = ? AND" - args = []any{options.TeamId} - } else { - query += " WHERE" - } - - query += ` Posts.CreateAt <= ? - AND Posts.CreateAt >= ? - GROUP BY DATE(FROM_UNIXTIME(Posts.CreateAt / 1000)) - ORDER BY Name DESC - LIMIT 30` - - end := utils.MillisFromTime(utils.EndOfDay(utils.Yesterday())) - start := utils.MillisFromTime(utils.StartOfDay(utils.Yesterday().AddDate(0, 0, -31))) + endDay := utils.Yesterday().Format("2006-01-02") + startDay := utils.Yesterday().AddDate(0, 0, -31).Format("2006-01-02") if options.YesterdayOnly { - start = utils.MillisFromTime(utils.StartOfDay(utils.Yesterday().AddDate(0, 0, -1))) + startDay = utils.Yesterday().AddDate(0, 0, -1).Format("2006-01-02") } - args = append(args, end, start) - - rows := model.AnalyticsRows{} - err := s.GetReplica().Select(&rows, query, args...) - if err != nil { - return nil, errors.Wrapf(err, "failed to find Posts with teamId=%s", options.TeamId) + // Use materialized views + if options.BotsOnly { + return s.countBotPostsByDay(options.TeamId, startDay, endDay) } - return rows, nil + return s.countPostsByDay(options.TeamId, startDay, endDay) } func (s *SqlPostStore) countByTeam(teamID string) (int64, error) { @@ -2534,11 +2319,7 @@ func (s *SqlPostStore) countByTeam(teamID string) (int64, error) { } func (s *SqlPostStore) AnalyticsPostCountByTeam(teamID string) (int64, error) { - if s.DriverName() == model.DatabaseDriverPostgres { - return s.countByTeam(teamID) - } - - return s.AnalyticsPostCount(&model.PostCountOptions{TeamId: teamID}) + return s.countByTeam(teamID) } func (s *SqlPostStore) AnalyticsPostCount(options *model.PostCountOptions) (int64, error) { @@ -2592,14 +2373,15 @@ func (s *SqlPostStore) AnalyticsPostCount(options *model.PostCountOptions) (int6 var v int64 err := s.GetReplica().GetBuilder(&v, query) if err != nil { - return 0, fmt.Errorf("post_tosql failed or failed to count Posts: %w", err) + return 0, fmt.Errorf("failed to count Posts: %w", err) } return v, nil } func (s *SqlPostStore) GetPostsCreatedAt(channelId string, time int64) ([]*model.Post, error) { - query := `SELECT * FROM Posts WHERE CreateAt = ? AND ChannelId = ?` + postColumns := strings.Join(postSliceColumns(), ", ") + query := "SELECT " + postColumns + " FROM Posts WHERE CreateAt = ? AND ChannelId = ?" posts := []*model.Post{} err := s.GetReplica().Select(&posts, query, time, channelId) @@ -2610,19 +2392,15 @@ func (s *SqlPostStore) GetPostsCreatedAt(channelId string, time int64) ([]*model } func (s *SqlPostStore) GetPostsByIds(postIds []string) ([]*model.Post, error) { - baseQuery := s.getQueryBuilder().Select("p.*, (SELECT count(*) FROM Posts WHERE Posts.RootId = (CASE WHEN p.RootId = '' THEN p.Id ELSE p.RootId END) AND Posts.DeleteAt = 0) as ReplyCount"). + query := s.getQueryBuilder(). + Select(postSliceColumnsWithName("p")...). + Column("(SELECT count(*) FROM Posts WHERE Posts.RootId = (CASE WHEN p.RootId = '' THEN p.Id ELSE p.RootId END) AND Posts.DeleteAt = 0) as ReplyCount"). From("Posts p"). Where(sq.Eq{"p.Id": postIds}). OrderBy("CreateAt DESC") - query, args, err := baseQuery.ToSql() - if err != nil { - return nil, errors.Wrap(err, "getPostsByIds_tosql") - } posts := []*model.Post{} - - err = s.GetReplica().Select(&posts, query, args...) - if err != nil { + if err := s.GetReplica().SelectBuilder(&posts, query); err != nil { return nil, errors.Wrap(err, "failed to find Posts") } if len(posts) == 0 { @@ -2632,23 +2410,12 @@ func (s *SqlPostStore) GetPostsByIds(postIds []string) ([]*model.Post, error) { } func (s *SqlPostStore) GetEditHistoryForPost(postId string) ([]*model.Post, error) { - builder := s.getQueryBuilder(). - Select("*"). - From("Posts"). + query := s.postsQuery. Where(sq.Eq{"Posts.OriginalId": postId}). OrderBy("Posts.EditAt DESC") - queryString, args, err := builder.ToSql() - if err != nil { - if err == sql.ErrNoRows { - return nil, store.NewErrNotFound("Post", postId) - } - return nil, errors.Wrap(err, "failed to find post history") - } - posts := []*model.Post{} - err = s.GetReplica().Select(&posts, queryString, args...) - if err != nil { + if err := s.GetReplica().SelectBuilder(&posts, query); err != nil { return nil, errors.Wrapf(err, "error getting posts edit history with postId=%s", postId) } @@ -2670,8 +2437,9 @@ func (s *SqlPostStore) GetPostsBatchForIndexing(startTime int64, startPostID str // More information in: https://github.com/mattermost/mattermost/pull/26517 // and https://community.mattermost.com/core/pl/ui5dz96shinetb8nq83myggbma + postColumnsPosts := strings.Join(postSliceColumnsWithName("Posts"), ", ") query := `SELECT - Posts.*, Channels.TeamId + ` + postColumnsPosts + `, Channels.TeamId FROM Posts LEFT JOIN Channels @@ -2722,12 +2490,7 @@ func (s *SqlPostStore) PermanentDeleteBatchForRetentionPolicies(retentionPolicyB } func (s *SqlPostStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) { - var query string - if s.DriverName() == model.DatabaseDriverPostgres { - query = "DELETE from Posts WHERE Id = any (array (SELECT Id FROM Posts WHERE CreateAt < ? LIMIT ?))" - } else { - query = "DELETE from Posts WHERE CreateAt < ? LIMIT ?" - } + query := "DELETE from Posts WHERE Id = any (array (SELECT Id FROM Posts WHERE CreateAt < ? LIMIT ?))" sqlResult, err := s.GetMaster().Exec(query, endTime, limit) if err != nil { @@ -2743,7 +2506,9 @@ func (s *SqlPostStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, func (s *SqlPostStore) GetOldest() (*model.Post, error) { var post model.Post - err := s.GetReplica().Get(&post, "SELECT * FROM Posts ORDER BY CreateAt LIMIT 1") + postColumns := strings.Join(postSliceColumns(), ", ") + query := "SELECT " + postColumns + " FROM Posts ORDER BY CreateAt LIMIT 1" + err := s.GetReplica().Get(&post, query) if err != nil { if err == sql.ErrNoRows { return nil, store.NewErrNotFound("Post", "none") @@ -2758,22 +2523,18 @@ func (s *SqlPostStore) GetOldest() (*model.Post, error) { func (s *SqlPostStore) determineMaxPostSize() int { var maxPostSizeBytes int32 - if s.DriverName() == model.DatabaseDriverPostgres { - // The Post.Message column in Postgres has historically been VARCHAR(4000), but - // may be manually enlarged to support longer posts. - if err := s.GetReplica().Get(&maxPostSizeBytes, ` - SELECT - COALESCE(character_maximum_length, 0) - FROM - information_schema.columns - WHERE - table_name = 'posts' - AND column_name = 'message' - `); err != nil { - mlog.Warn("Unable to determine the maximum supported post size", mlog.Err(err)) - } - } else { - mlog.Error("No implementation found to determine the maximum supported post size") + // The Post.Message column in Postgres has historically been VARCHAR(4000), but + // may be manually enlarged to support longer posts. + if err := s.GetReplica().Get(&maxPostSizeBytes, ` + SELECT + COALESCE(character_maximum_length, 0) + FROM + information_schema.columns + WHERE + table_name = 'posts' + AND column_name = 'message' + `); err != nil { + mlog.Warn("Unable to determine the maximum supported post size", mlog.Err(err)) } // Assume a worst-case representation of four bytes per rune. @@ -2827,9 +2588,9 @@ func (s *SqlPostStore) GetParentsForExportAfter(limit int, afterId string, inclu aggFn := "COALESCE(json_agg(u1.username) FILTER (WHERE u1.username IS NOT NULL), '[]')" result := []*model.PostForExport{} - builder := s.getQueryBuilder(). + query := s.getQueryBuilder(). Select(fmt.Sprintf("%s, Users.Username as Username, Teams.Name as TeamName, Channels.Name as ChannelName, %s as FlaggedBy", strings.Join(postSliceColumnsWithName("p1"), ", "), aggFn)). - FromSelect(sq.Select("*").From("Posts").Where(sq.Eq{"Posts.Id": rootIds}), "p1"). + FromSelect(sq.Select(postSliceColumnsWithName("Posts")...).From("Posts").Where(sq.Eq{"Posts.Id": rootIds}), "p1"). LeftJoin("Preferences ON p1.Id = Preferences.Name"). LeftJoin("Users u1 ON Preferences.UserId = u1.Id"). InnerJoin("Channels ON p1.ChannelId = Channels.Id"). @@ -2839,13 +2600,7 @@ func (s *SqlPostStore) GetParentsForExportAfter(limit int, afterId string, inclu GroupBy(fmt.Sprintf("%s, Users.Username, Teams.Name, Channels.Name", strings.Join(postSliceColumnsWithName("p1"), ", "))). OrderBy("p1.Id") - query, args, err := builder.ToSql() - if err != nil { - return nil, errors.Wrap(err, "postsForExport_toSql") - } - - err = s.GetSearchReplicaX().Select(&result, query, args...) - if err != nil { + if err := s.GetSearchReplicaX().SelectBuilder(&result, query); err != nil { return nil, errors.Wrap(err, "failed to find Posts") } @@ -2864,8 +2619,9 @@ func (s *SqlPostStore) GetRepliesForExport(rootId string) ([]*model.ReplyForExpo aggFn := "COALESCE(json_agg(u1.username) FILTER (WHERE u1.username IS NOT NULL), '[]')" result := []*model.ReplyForExport{} - qb := s.getQueryBuilder().Select(fmt.Sprintf("Posts.*, u2.Username as Username, %s as FlaggedBy", aggFn)). - From("Posts"). + query := s.postsQuery. + Column("u2.Username as Username"). + Column(fmt.Sprintf("%s as FlaggedBy", aggFn)). LeftJoin("Preferences ON Posts.Id = Preferences.Name"). LeftJoin("Users u1 ON Preferences.UserId = u1.Id"). InnerJoin("Users u2 ON Posts.UserId = u2.Id"). @@ -2873,13 +2629,7 @@ func (s *SqlPostStore) GetRepliesForExport(rootId string) ([]*model.ReplyForExpo GroupBy("Posts.Id, u2.Username"). OrderBy("Posts.Id") - query, args, err := qb.ToSql() - if err != nil { - return nil, errors.Wrap(err, "postsForExport_toSql") - } - - err = s.GetSearchReplicaX().Select(&result, query, args...) - if err != nil { + if err := s.GetSearchReplicaX().SelectBuilder(&result, query); err != nil { return nil, errors.Wrap(err, "failed to find Posts") } @@ -2891,7 +2641,9 @@ func (s *SqlPostStore) GetDirectPostParentsForExportAfter(limit int, afterId str result := []*model.DirectPostForExport{} query := s.getQueryBuilder(). - Select(fmt.Sprintf("p.*, u2.Username as User, %s as FlaggedBy", aggFn)). + Select(postSliceColumnsWithName("p")...). + Column("u2.Username as User"). + Column(fmt.Sprintf("%s as FlaggedBy", aggFn)). From("Posts p"). LeftJoin("Preferences ON p.Id = Preferences.Name"). LeftJoin("Users u1 ON Preferences.UserId = u1.Id"). @@ -2913,13 +2665,8 @@ func (s *SqlPostStore) GetDirectPostParentsForExportAfter(limit int, afterId str ) } - queryString, args, err := query.ToSql() - if err != nil { - return nil, errors.Wrap(err, "post_tosql") - } - - if err2 := s.GetReplica().Select(&result, queryString, args...); err2 != nil { - return nil, errors.Wrap(err2, "failed to find Posts") + if err := s.GetReplica().SelectBuilder(&result, query); err != nil { + return nil, errors.Wrap(err, "failed to find Posts") } var channelIds []string for _, p := range result { @@ -2933,13 +2680,8 @@ func (s *SqlPostStore) GetDirectPostParentsForExportAfter(limit int, afterId str "cm.ChannelId": channelIds, }) - queryString, args, err = query.ToSql() - if err != nil { - return nil, errors.Wrap(err, "post_tosql") - } - channelMembers := []*model.ChannelMemberForExport{} - if err = s.GetReplica().Select(&channelMembers, queryString, args...); err != nil { + if err := s.GetReplica().SelectBuilder(&channelMembers, query); err != nil { return nil, errors.Wrap(err, "failed to find ChannelMembers") } @@ -3021,14 +2763,9 @@ func (s *SqlPostStore) GetOldestEntityCreationTime() (int64, error) { UNION (SELECT MIN(createat) min_createat FROM Channels) ) entities`) - queryString, args, err := query.ToSql() - if err != nil { - return -1, errors.Wrap(err, "post_tosql") - } var oldest int64 - err = s.GetReplica().Get(&oldest, queryString, args...) - if err != nil { + if err := s.GetReplica().GetBuilder(&oldest, query); err != nil { return -1, errors.Wrap(err, "unable to scan oldest entity creation time") } return oldest, nil @@ -3095,17 +2832,12 @@ func (s *SqlPostStore) permanentDeleteTemporaryPosts(transaction *sqlxTxWrapper, // deleteThread marks a thread as deleted at the given time. func (s *SqlPostStore) deleteThread(transaction *sqlxTxWrapper, postId string, deleteAtTime int64) error { - queryString, args, err := s.getQueryBuilder(). + query := s.getQueryBuilder(). Update("Threads"). Set("ThreadDeleteAt", deleteAtTime). - Where(sq.Eq{"PostId": postId}). - ToSql() - if err != nil { - return errors.Wrapf(err, "failed to create SQL query to mark thread for root post %s as deleted", postId) - } + Where(sq.Eq{"PostId": postId}) - _, err = transaction.Exec(queryString, args...) - if err != nil { + if _, err := transaction.ExecBuilder(query); err != nil { return errors.Wrapf(err, "failed to mark thread for root post %s as deleted", postId) } @@ -3113,20 +2845,13 @@ func (s *SqlPostStore) deleteThread(transaction *sqlxTxWrapper, postId string, d } func (s *SqlPostStore) deleteThreadFiles(transaction *sqlxTxWrapper, postID string, deleteAtTime int64) error { - var query sq.UpdateBuilder - if s.DriverName() == model.DatabaseDriverPostgres { - query = s.getQueryBuilder().Update("FileInfo"). - Set("DeleteAt", deleteAtTime). - From("Posts") - } else { - query = s.getQueryBuilder().Update("FileInfo", "Posts"). - Set("FileInfo.DeleteAt", deleteAtTime) - } - - query = query.Where(sq.And{ - sq.Expr("FileInfo.PostId = Posts.Id"), - sq.Eq{"Posts.RootId": postID}, - }) + query := s.getQueryBuilder().Update("FileInfo"). + Set("DeleteAt", deleteAtTime). + From("Posts"). + Where(sq.And{ + sq.Expr("FileInfo.PostId = Posts.Id"), + sq.Eq{"Posts.RootId": postID}, + }) _, err := transaction.ExecBuilder(query) if err != nil { @@ -3140,24 +2865,17 @@ func (s *SqlPostStore) deleteThreadFiles(transaction *sqlxTxWrapper, postID stri // list as necessary. func (s *SqlPostStore) updateThreadAfterReplyDeletion(transaction *sqlxTxWrapper, rootId string, userId string) error { if rootId != "" { - queryString, args, err := s.getQueryBuilder(). + query := s.getQueryBuilder(). Select("COUNT(Posts.Id)"). From("Posts"). Where(sq.And{ sq.Eq{"Posts.RootId": rootId}, sq.Eq{"Posts.UserId": userId}, sq.Eq{"Posts.DeleteAt": 0}, - }). - ToSql() - - if err != nil { - return errors.Wrap(err, "failed to create SQL query to count user's posts") - } + }) var count int64 - err = transaction.Get(&count, queryString, args...) - - if err != nil { + if err := transaction.GetBuilder(&count, query); err != nil { return errors.Wrap(err, "failed to count user's posts in thread") } @@ -3165,14 +2883,7 @@ func (s *SqlPostStore) updateThreadAfterReplyDeletion(transaction *sqlxTxWrapper updateQuery := s.getQueryBuilder().Update("Threads") if count == 0 { - if s.DriverName() == model.DatabaseDriverPostgres { - updateQuery = updateQuery.Set("Participants", sq.Expr("Participants - ?", userId)) - } else { - updateQuery = updateQuery. - Set("Participants", sq.Expr( - `IFNULL(JSON_REMOVE(Participants, JSON_UNQUOTE(JSON_SEARCH(Participants, 'one', ?))), Participants)`, userId, - )) - } + updateQuery = updateQuery.Set("Participants", sq.Expr("Participants - ?", userId)) } lastReplyAtSubquery := sq.Select("COALESCE(MAX(CreateAt), 0)"). @@ -3189,22 +2900,15 @@ func (s *SqlPostStore) updateThreadAfterReplyDeletion(transaction *sqlxTxWrapper "DeleteAt": 0, }) - updateQueryString, updateArgs, err := updateQuery. + updateQuery = updateQuery. Set("LastReplyAt", lastReplyAtSubquery). Set("ReplyCount", lastReplyCountSubquery). Where(sq.And{ sq.Eq{"PostId": rootId}, sq.Gt{"ReplyCount": 0}, - }). - ToSql() + }) - if err != nil { - return errors.Wrap(err, "failed to create SQL query to update thread") - } - - _, err = transaction.Exec(updateQueryString, updateArgs...) - - if err != nil { + if _, err := transaction.ExecBuilder(updateQuery); err != nil { return errors.Wrap(err, "failed to update Threads") } } @@ -3257,7 +2961,7 @@ func (s *SqlPostStore) updateThreadsFromPosts(transaction *sqlxTxWrapper, posts if len(rootIds) == 0 { return nil } - threadsByRootsSql, threadsByRootsArgs, err := s.getQueryBuilder(). + query := s.getQueryBuilder(). Select( "Threads.PostId", "Threads.ChannelId", @@ -3267,15 +2971,10 @@ func (s *SqlPostStore) updateThreadsFromPosts(transaction *sqlxTxWrapper, posts "COALESCE(Threads.ThreadDeleteAt, 0) AS DeleteAt", ). From("Threads"). - Where(sq.Eq{"Threads.PostId": rootIds}). - ToSql() - if err != nil { - return errors.Wrap(err, "updateThreadsFromPosts_ToSql") - } + Where(sq.Eq{"Threads.PostId": rootIds}) threadsByRoots := []*model.Thread{} - err = transaction.Select(&threadsByRoots, threadsByRootsSql, threadsByRootsArgs...) - if err != nil { + if err := transaction.SelectBuilder(&threadsByRoots, query); err != nil { return err } @@ -3390,53 +3089,21 @@ func (s *SqlPostStore) SetPostReminder(reminder *model.PostReminder) error { Values(reminder.PostId, reminder.UserId, reminder.TargetTime). SuffixExpr(sq.Expr("ON CONFLICT (postid, userid) DO UPDATE SET TargetTime = ?", reminder.TargetTime)) - sql, args, err := query.ToSql() - if err != nil { - return errors.Wrap(err, "setPostReminder_tosql") + if _, err := transaction.ExecBuilder(query); err != nil { + return errors.Wrap(err, "failed to insert post reminder") } - if _, err2 := transaction.Exec(sql, args...); err2 != nil { - return errors.Wrap(err2, "failed to insert post reminder") - } - if err = transaction.Commit(); err != nil { + if err := transaction.Commit(); err != nil { return errors.Wrap(err, "commit_transaction") } return nil } -func (s *SqlPostStore) GetPostReminders(now int64) (_ []*model.PostReminder, err error) { +func (s *SqlPostStore) GetPostReminders(now int64) ([]*model.PostReminder, error) { reminders := []*model.PostReminder{} - - transaction, err := s.GetMaster().Beginx() + err := s.GetMaster().Select(&reminders, `DELETE FROM PostReminders WHERE TargetTime <= $1 RETURNING PostId, UserId`, now) if err != nil { - return nil, errors.Wrap(err, "begin_transaction") + return nil, errors.Wrap(err, "failed to get and delete post reminders") } - defer finalizeTransactionX(transaction, &err) - - err = transaction.Select(&reminders, `SELECT PostId, UserId - FROM PostReminders - WHERE TargetTime <= ?`, now) - if err != nil && err != sql.ErrNoRows { - return nil, errors.Wrap(err, "failed to get post reminders") - } - - if err == sql.ErrNoRows { - // No need to execute delete statement if there's nothing to delete. - return reminders, nil - } - - // TODO: https://mattermost.atlassian.net/browse/MM-63368 - // Postgres supports RETURNING * in a DELETE statement, but MySQL doesn't. - // So we are stuck with 2 queries. Not taking separate paths for Postgres - // and MySQL for simplicity. - _, err = transaction.Exec(`DELETE from PostReminders WHERE TargetTime <= ?`, now) - if err != nil { - return nil, errors.Wrap(err, "failed to delete post reminders") - } - - if err = transaction.Commit(); err != nil { - return nil, errors.Wrap(err, "commit_transaction") - } - return reminders, nil } @@ -3466,19 +3133,17 @@ func (s *SqlPostStore) GetPostReminderMetadata(postID string) (*store.PostRemind } func (s *SqlPostStore) RefreshPostStats() error { - if s.DriverName() == model.DatabaseDriverPostgres { - // CONCURRENTLY is not used deliberately because as per Postgres docs, - // not using CONCURRENTLY takes less resources and completes faster - // at the expense of locking the mat view. Since viewing admin console - // is not a very frequent activity, we accept the tradeoff to let the - // refresh happen as fast as possible. - if _, err := s.GetMaster().Exec("REFRESH MATERIALIZED VIEW posts_by_team_day"); err != nil { - return errors.Wrap(err, "error refreshing materialized view posts_by_team_day") - } + // CONCURRENTLY is not used deliberately because as per Postgres docs, + // not using CONCURRENTLY takes less resources and completes faster + // at the expense of locking the mat view. Since viewing admin console + // is not a very frequent activity, we accept the tradeoff to let the + // refresh happen as fast as possible. + if _, err := s.GetMaster().Exec("REFRESH MATERIALIZED VIEW posts_by_team_day"); err != nil { + return errors.Wrap(err, "error refreshing materialized view posts_by_team_day") + } - if _, err := s.GetMaster().Exec("REFRESH MATERIALIZED VIEW bot_posts_by_team_day"); err != nil { - return errors.Wrap(err, "error refreshing materialized view bot_posts_by_team_day") - } + if _, err := s.GetMaster().Exec("REFRESH MATERIALIZED VIEW bot_posts_by_team_day"); err != nil { + return errors.Wrap(err, "error refreshing materialized view bot_posts_by_team_day") } return nil diff --git a/server/channels/store/sqlstore/preference_store.go b/server/channels/store/sqlstore/preference_store.go index 68c5620013e..2f0d5dbf071 100644 --- a/server/channels/store/sqlstore/preference_store.go +++ b/server/channels/store/sqlstore/preference_store.go @@ -224,15 +224,12 @@ func (s SqlPreferenceStore) DeleteCategoryAndName(category string, name string) // DeleteOrphanedRows removes entries from Preferences (flagged post) when a // corresponding post no longer exists. func (s *SqlPreferenceStore) DeleteOrphanedRows(limit int) (deleted int64, err error) { - // We need the extra level of nesting to deal with MySQL's locking const query = ` - DELETE FROM Preferences WHERE Name IN ( - SELECT Name FROM ( - SELECT Preferences.Name FROM Preferences - LEFT JOIN Posts ON Preferences.Name = Posts.Id - WHERE Posts.Id IS NULL AND Category = ? - LIMIT ? - ) AS A + DELETE FROM Preferences WHERE ctid IN ( + SELECT Preferences.ctid FROM Preferences + LEFT JOIN Posts ON Preferences.Name = Posts.Id + WHERE Posts.Id IS NULL AND Category = $1 + LIMIT $2 )` result, err := s.GetMaster().Exec(query, model.PreferenceCategoryFlaggedPost, limit) @@ -284,12 +281,8 @@ func (s SqlPreferenceStore) CleanupFlagsBatch(limit int64) (int64, error) { // Delete preference for limit_visible_dms_gms where their value is greater than "40" or less than "1" func (s SqlPreferenceStore) DeleteInvalidVisibleDmsGms() (int64, error) { - var queryString string - var args []any - var err error - // We need to pad the value field with zeros when doing comparison's because the value is stored as a string. - // Having them the same length allows Postgres/MySQL to compare them correctly. + // Having them the same length allows Postgres to compare them correctly. whereClause := sq.And{ sq.Eq{"Category": model.PreferenceCategorySidebarSettings}, sq.Eq{"Name": model.PreferenceLimitVisibleDmsGms}, @@ -298,28 +291,17 @@ func (s SqlPreferenceStore) DeleteInvalidVisibleDmsGms() (int64, error) { sq.Lt{"SUBSTRING(CONCAT('000000000000000', Value), LENGTH(Value) + 1, 15)": "000000000000001"}, }, } - if s.DriverName() == "postgres" { - subQuery := s.getQueryBuilder(). - Select("UserId, Category, Name"). - From("Preferences"). - Where(whereClause). - Limit(100) - queryString, args, err = s.getQueryBuilder(). - Delete("Preferences"). - Where(sq.Expr("(userid, category, name) IN (?)", subQuery)). - ToSql() - if err != nil { - return int64(0), errors.Wrap(err, "could not build sql query to delete preference") - } - } else { - queryString, args, err = s.getQueryBuilder(). - Delete("Preferences"). - Where(whereClause). - Limit(100). - ToSql() - if err != nil { - return int64(0), errors.Wrap(err, "could not build sql query to delete preference") - } + subQuery := s.getQueryBuilder(). + Select("UserId, Category, Name"). + From("Preferences"). + Where(whereClause). + Limit(100) + queryString, args, err := s.getQueryBuilder(). + Delete("Preferences"). + Where(sq.Expr("(userid, category, name) IN (?)", subQuery)). + ToSql() + if err != nil { + return int64(0), errors.Wrap(err, "could not build sql query to delete preference") } result, err := s.GetMaster().Exec(queryString, args...) diff --git a/server/channels/store/sqlstore/property_field_store.go b/server/channels/store/sqlstore/property_field_store.go index 5c5eb06d606..40d19aa4c60 100644 --- a/server/channels/store/sqlstore/property_field_store.go +++ b/server/channels/store/sqlstore/property_field_store.go @@ -200,7 +200,6 @@ func (s *SqlPropertyFieldStore) Update(groupID string, fields []*model.PropertyF defer finalizeTransactionX(transaction, &err) updateTime := model.GetMillis() - isPostgres := s.DriverName() == model.DatabaseDriverPostgres nameCase := sq.Case("id") typeCase := sq.Case("id") attrsCase := sq.Case("id") @@ -217,21 +216,12 @@ func (s *SqlPropertyFieldStore) Update(groupID string, fields []*model.PropertyF ids[i] = field.ID whenID := sq.Expr("?", field.ID) - if isPostgres { - nameCase = nameCase.When(whenID, sq.Expr("?::text", field.Name)) - typeCase = typeCase.When(whenID, sq.Expr("?::property_field_type", field.Type)) - attrsCase = attrsCase.When(whenID, sq.Expr("?::jsonb", field.Attrs)) - targetIDCase = targetIDCase.When(whenID, sq.Expr("?::text", field.TargetID)) - targetTypeCase = targetTypeCase.When(whenID, sq.Expr("?::text", field.TargetType)) - deleteAtCase = deleteAtCase.When(whenID, sq.Expr("?::bigint", field.DeleteAt)) - } else { - nameCase = nameCase.When(whenID, sq.Expr("?", field.Name)) - typeCase = typeCase.When(whenID, sq.Expr("?", field.Type)) - attrsCase = attrsCase.When(whenID, sq.Expr("?", field.Attrs)) - targetIDCase = targetIDCase.When(whenID, sq.Expr("?", field.TargetID)) - targetTypeCase = targetTypeCase.When(whenID, sq.Expr("?", field.TargetType)) - deleteAtCase = deleteAtCase.When(whenID, sq.Expr("?", field.DeleteAt)) - } + nameCase = nameCase.When(whenID, sq.Expr("?::text", field.Name)) + typeCase = typeCase.When(whenID, sq.Expr("?::property_field_type", field.Type)) + attrsCase = attrsCase.When(whenID, sq.Expr("?::jsonb", field.Attrs)) + targetIDCase = targetIDCase.When(whenID, sq.Expr("?::text", field.TargetID)) + targetTypeCase = targetTypeCase.When(whenID, sq.Expr("?::text", field.TargetType)) + deleteAtCase = deleteAtCase.When(whenID, sq.Expr("?::bigint", field.DeleteAt)) } builder := s.getQueryBuilder(). diff --git a/server/channels/store/sqlstore/property_value_store.go b/server/channels/store/sqlstore/property_value_store.go index 880594a1672..d366a6a1a76 100644 --- a/server/channels/store/sqlstore/property_value_store.go +++ b/server/channels/store/sqlstore/property_value_store.go @@ -203,7 +203,6 @@ func (s *SqlPropertyValueStore) Update(groupID string, values []*model.PropertyV defer finalizeTransactionX(transaction, &err) updateTime := model.GetMillis() - isPostgres := s.DriverName() == model.DatabaseDriverPostgres valueCase := sq.Case("id") deleteAtCase := sq.Case("id") ids := make([]string, len(values)) @@ -220,13 +219,8 @@ func (s *SqlPropertyValueStore) Update(groupID string, values []*model.PropertyV valueJSON = AppendBinaryFlag(valueJSON) } - if isPostgres { - valueCase = valueCase.When(sq.Expr("?", value.ID), sq.Expr("?::jsonb", valueJSON)) - deleteAtCase = deleteAtCase.When(sq.Expr("?", value.ID), sq.Expr("?::bigint", value.DeleteAt)) - } else { - valueCase = valueCase.When(sq.Expr("?", value.ID), sq.Expr("?", valueJSON)) - deleteAtCase = deleteAtCase.When(sq.Expr("?", value.ID), sq.Expr("?", value.DeleteAt)) - } + valueCase = valueCase.When(sq.Expr("?", value.ID), sq.Expr("?::jsonb", valueJSON)) + deleteAtCase = deleteAtCase.When(sq.Expr("?", value.ID), sq.Expr("?::bigint", value.DeleteAt)) } builder := s.getQueryBuilder(). diff --git a/server/channels/store/sqlstore/reaction_store.go b/server/channels/store/sqlstore/reaction_store.go index df06f265c72..9b20a70ccbd 100644 --- a/server/channels/store/sqlstore/reaction_store.go +++ b/server/channels/store/sqlstore/reaction_store.go @@ -345,12 +345,7 @@ func (s *SqlReactionStore) DeleteOrphanedRowsByIds(r *model.RetentionIdsForDelet } func (s *SqlReactionStore) PermanentDeleteBatch(endTime int64, limit int64) (int64, error) { - var query string - if s.DriverName() == "postgres" { - query = "DELETE from Reactions WHERE CreateAt = any (array (SELECT CreateAt FROM Reactions WHERE CreateAt < ? LIMIT ?))" - } else { - query = "DELETE from Reactions WHERE CreateAt < ? LIMIT ?" - } + query := "DELETE from Reactions WHERE CreateAt = any (array (SELECT CreateAt FROM Reactions WHERE CreateAt < ? LIMIT ?))" sqlResult, err := s.GetMaster().Exec(query, endTime, limit) if err != nil { diff --git a/server/channels/store/sqlstore/recap_store.go b/server/channels/store/sqlstore/recap_store.go new file mode 100644 index 00000000000..5d2327556eb --- /dev/null +++ b/server/channels/store/sqlstore/recap_store.go @@ -0,0 +1,299 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "database/sql" + "encoding/json" + "fmt" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/v8/channels/store" + sq "github.com/mattermost/squirrel" + "github.com/pkg/errors" +) + +var ( + recapColumns = []string{ + "Id", + "UserId", + "Title", + "CreateAt", + "UpdateAt", + "DeleteAt", + "ReadAt", + "TotalMessageCount", + "Status", + "BotID", + } + + recapChannelColumns = []string{ + "Id", + "RecapId", + "ChannelId", + "ChannelName", + "Highlights", + "ActionItems", + "SourcePostIds", + "CreateAt", + } +) + +type SqlRecapStore struct { + *SqlStore + + recapSelectQuery sq.SelectBuilder + recapChannelSelectQuery sq.SelectBuilder +} + +func newSqlRecapStore(sqlStore *SqlStore) store.RecapStore { + s := &SqlRecapStore{ + SqlStore: sqlStore, + } + + s.recapSelectQuery = s.getQueryBuilder(). + Select(recapColumns...). + From("Recaps") + + s.recapChannelSelectQuery = s.getQueryBuilder(). + Select(recapChannelColumns...). + From("RecapChannels") + + return s +} + +func (s *SqlRecapStore) recapToMap(recap *model.Recap) map[string]any { + return map[string]any{ + "Id": recap.Id, + "UserId": recap.UserId, + "Title": recap.Title, + "CreateAt": recap.CreateAt, + "UpdateAt": recap.UpdateAt, + "DeleteAt": recap.DeleteAt, + "ReadAt": recap.ReadAt, + "TotalMessageCount": recap.TotalMessageCount, + "Status": recap.Status, + "BotID": recap.BotID, + } +} + +func (s *SqlRecapStore) recapChannelToMap(rc *model.RecapChannel) (map[string]any, error) { + highlightsJSON, err := json.Marshal(rc.Highlights) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal Highlights") + } + + actionItemsJSON, err := json.Marshal(rc.ActionItems) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal ActionItems") + } + + sourcePostIdsJSON, err := json.Marshal(rc.SourcePostIds) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal SourcePostIds") + } + + return map[string]any{ + "Id": rc.Id, + "RecapId": rc.RecapId, + "ChannelId": rc.ChannelId, + "ChannelName": rc.ChannelName, + "Highlights": string(highlightsJSON), + "ActionItems": string(actionItemsJSON), + "SourcePostIds": string(sourcePostIdsJSON), + "CreateAt": rc.CreateAt, + }, nil +} + +func (s *SqlRecapStore) SaveRecap(recap *model.Recap) (*model.Recap, error) { + query := s.getQueryBuilder(). + Insert("Recaps"). + SetMap(s.recapToMap(recap)) + + if _, err := s.GetMaster().ExecBuilder(query); err != nil { + return nil, errors.Wrap(err, "failed to save Recap") + } + + return recap, nil +} + +func (s *SqlRecapStore) GetRecap(id string) (*model.Recap, error) { + var recap model.Recap + query := s.recapSelectQuery.Where(sq.Eq{"Id": id}) + + if err := s.GetReplica().GetBuilder(&recap, query); err != nil { + if err == sql.ErrNoRows { + return nil, store.NewErrNotFound("Recap", id) + } + return nil, errors.Wrapf(err, "failed to get Recap with id=%s", id) + } + + return &recap, nil +} + +func (s *SqlRecapStore) GetRecapsForUser(userId string, page, perPage int) ([]*model.Recap, error) { + offset := page * perPage + var recaps []*model.Recap + + query := s.recapSelectQuery. + Where(sq.Eq{"UserId": userId, "DeleteAt": 0}). + OrderBy("CreateAt DESC"). + Limit(uint64(perPage)). + Offset(uint64(offset)) + + if err := s.GetReplica().SelectBuilder(&recaps, query); err != nil { + return nil, errors.Wrapf(err, "failed to get Recaps for userId=%s", userId) + } + + return recaps, nil +} + +func (s *SqlRecapStore) UpdateRecap(recap *model.Recap) (*model.Recap, error) { + query := s.getQueryBuilder(). + Update("Recaps"). + SetMap(map[string]any{ + "Title": recap.Title, + "UpdateAt": recap.UpdateAt, + "TotalMessageCount": recap.TotalMessageCount, + "Status": recap.Status, + }). + Where(sq.Eq{"Id": recap.Id}) + + if _, err := s.GetMaster().ExecBuilder(query); err != nil { + return nil, errors.Wrapf(err, "failed to update Recap with id=%s", recap.Id) + } + + return recap, nil +} + +func (s *SqlRecapStore) UpdateRecapStatus(id, status string) error { + updateAt := model.GetMillis() + + query := s.getQueryBuilder(). + Update("Recaps"). + SetMap(map[string]any{ + "Status": status, + "UpdateAt": updateAt, + }). + Where(sq.Eq{"Id": id}) + + if _, err := s.GetMaster().ExecBuilder(query); err != nil { + return errors.Wrapf(err, "failed to update Recap status for id=%s", id) + } + + return nil +} + +func (s *SqlRecapStore) MarkRecapAsRead(id string) error { + now := model.GetMillis() + + query := s.getQueryBuilder(). + Update("Recaps"). + SetMap(map[string]any{ + "ReadAt": now, + "UpdateAt": now, + }). + Where(sq.Eq{"Id": id, "ReadAt": 0}) + + if _, err := s.GetMaster().ExecBuilder(query); err != nil { + return errors.Wrapf(err, "failed to mark Recap as read for id=%s", id) + } + + return nil +} + +func (s *SqlRecapStore) DeleteRecap(id string) error { + deleteAt := model.GetMillis() + + query := s.getQueryBuilder(). + Update("Recaps"). + SetMap(map[string]any{ + "DeleteAt": deleteAt, + }). + Where(sq.Eq{"Id": id}) + + if _, err := s.GetMaster().ExecBuilder(query); err != nil { + return errors.Wrapf(err, "failed to delete Recap with id=%s", id) + } + + return nil +} + +func (s *SqlRecapStore) DeleteRecapChannels(recapId string) error { + query := s.getQueryBuilder(). + Delete("RecapChannels"). + Where(sq.Eq{"RecapId": recapId}) + + if _, err := s.GetMaster().ExecBuilder(query); err != nil { + return errors.Wrapf(err, "failed to delete RecapChannels for recapId=%s", recapId) + } + + return nil +} + +func (s *SqlRecapStore) SaveRecapChannel(recapChannel *model.RecapChannel) error { + rcMap, err := s.recapChannelToMap(recapChannel) + if err != nil { + return err + } + + query := s.getQueryBuilder(). + Insert("RecapChannels"). + SetMap(rcMap) + + if _, err := s.GetMaster().ExecBuilder(query); err != nil { + return errors.Wrap(err, "failed to save RecapChannel") + } + + return nil +} + +func (s *SqlRecapStore) GetRecapChannelsByRecapId(recapId string) ([]*model.RecapChannel, error) { + query := s.recapChannelSelectQuery. + Where(sq.Eq{"RecapId": recapId}). + OrderBy("CreateAt ASC") + + var dbRecapChannels []struct { + Id string + RecapId string + ChannelId string + ChannelName string + Highlights string + ActionItems string + SourcePostIds string + CreateAt int64 + } + + if err := s.GetReplica().SelectBuilder(&dbRecapChannels, query); err != nil { + return nil, errors.Wrapf(err, "failed to get RecapChannels for recapId=%s", recapId) + } + + recapChannels := make([]*model.RecapChannel, 0, len(dbRecapChannels)) + for _, dbRC := range dbRecapChannels { + rc := &model.RecapChannel{ + Id: dbRC.Id, + RecapId: dbRC.RecapId, + ChannelId: dbRC.ChannelId, + ChannelName: dbRC.ChannelName, + CreateAt: dbRC.CreateAt, + } + + // Unmarshal JSON strings back to arrays + if err := json.Unmarshal([]byte(dbRC.Highlights), &rc.Highlights); err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("failed to unmarshal Highlights for recapChannel id=%s", dbRC.Id)) + } + + if err := json.Unmarshal([]byte(dbRC.ActionItems), &rc.ActionItems); err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("failed to unmarshal ActionItems for recapChannel id=%s", dbRC.Id)) + } + + if err := json.Unmarshal([]byte(dbRC.SourcePostIds), &rc.SourcePostIds); err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("failed to unmarshal SourcePostIds for recapChannel id=%s", dbRC.Id)) + } + + recapChannels = append(recapChannels, rc) + } + + return recapChannels, nil +} diff --git a/server/channels/store/sqlstore/recap_store_test.go b/server/channels/store/sqlstore/recap_store_test.go new file mode 100644 index 00000000000..4f4778221a9 --- /dev/null +++ b/server/channels/store/sqlstore/recap_store_test.go @@ -0,0 +1,195 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package sqlstore + +import ( + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRecapStore(t *testing.T) { + StoreTest(t, func(t *testing.T, rctx request.CTX, ss store.Store) { + t.Run("SaveAndGetRecap", func(t *testing.T) { + recap := &model.Recap{ + Id: model.NewId(), + UserId: model.NewId(), + Title: "Test Recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 10, + Status: model.RecapStatusPending, + BotID: "test-bot-id", + } + + savedRecap, err := ss.Recap().SaveRecap(recap) + require.NoError(t, err) + assert.Equal(t, recap.Id, savedRecap.Id) + assert.Equal(t, recap.UserId, savedRecap.UserId) + assert.Equal(t, recap.Title, savedRecap.Title) + assert.Equal(t, recap.BotID, savedRecap.BotID) + + retrievedRecap, err := ss.Recap().GetRecap(recap.Id) + require.NoError(t, err) + assert.Equal(t, recap.Id, retrievedRecap.Id) + assert.Equal(t, recap.UserId, retrievedRecap.UserId) + assert.Equal(t, recap.Title, retrievedRecap.Title) + assert.Equal(t, recap.TotalMessageCount, retrievedRecap.TotalMessageCount) + assert.Equal(t, recap.Status, retrievedRecap.Status) + assert.Equal(t, recap.BotID, retrievedRecap.BotID) + }) + + t.Run("GetRecapsForUser", func(t *testing.T) { + userId := model.NewId() + + // Create multiple recaps for the same user + for range 3 { + recap := &model.Recap{ + Id: model.NewId(), + UserId: userId, + Title: "Test Recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 10, + Status: model.RecapStatusCompleted, + BotID: "test-bot-id", + } + _, err := ss.Recap().SaveRecap(recap) + require.NoError(t, err) + } + + recaps, err := ss.Recap().GetRecapsForUser(userId, 0, 10) + require.NoError(t, err) + assert.Len(t, recaps, 3) + }) + + t.Run("UpdateRecapStatus", func(t *testing.T) { + recap := &model.Recap{ + Id: model.NewId(), + UserId: model.NewId(), + Title: "Test Recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 10, + Status: model.RecapStatusPending, + BotID: "test-bot-id", + } + + _, err := ss.Recap().SaveRecap(recap) + require.NoError(t, err) + + err = ss.Recap().UpdateRecapStatus(recap.Id, model.RecapStatusCompleted) + require.NoError(t, err) + + updatedRecap, err := ss.Recap().GetRecap(recap.Id) + require.NoError(t, err) + assert.Equal(t, model.RecapStatusCompleted, updatedRecap.Status) + }) + + t.Run("SaveAndGetRecapChannels", func(t *testing.T) { + recapId := model.NewId() + + // Create a recap first + recap := &model.Recap{ + Id: recapId, + UserId: model.NewId(), + Title: "Test Recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 10, + Status: model.RecapStatusPending, + BotID: "test-bot-id", + } + _, err := ss.Recap().SaveRecap(recap) + require.NoError(t, err) + + // Create recap channels + recapChannel1 := &model.RecapChannel{ + Id: model.NewId(), + RecapId: recapId, + ChannelId: model.NewId(), + ChannelName: "Test Channel 1", + Highlights: []string{"Highlight 1", "Highlight 2"}, + ActionItems: []string{"Action 1"}, + SourcePostIds: []string{model.NewId(), model.NewId()}, + CreateAt: model.GetMillis(), + } + + recapChannel2 := &model.RecapChannel{ + Id: model.NewId(), + RecapId: recapId, + ChannelId: model.NewId(), + ChannelName: "Test Channel 2", + Highlights: []string{}, + ActionItems: []string{"Action 2", "Action 3"}, + SourcePostIds: []string{model.NewId()}, + CreateAt: model.GetMillis(), + } + + err = ss.Recap().SaveRecapChannel(recapChannel1) + require.NoError(t, err) + + err = ss.Recap().SaveRecapChannel(recapChannel2) + require.NoError(t, err) + + // Retrieve recap channels + channels, err := ss.Recap().GetRecapChannelsByRecapId(recapId) + require.NoError(t, err) + assert.Len(t, channels, 2) + + // Verify data integrity + for _, ch := range channels { + if ch.Id == recapChannel1.Id { + assert.Equal(t, recapChannel1.ChannelName, ch.ChannelName) + assert.Equal(t, recapChannel1.Highlights, ch.Highlights) + assert.Equal(t, recapChannel1.ActionItems, ch.ActionItems) + assert.Equal(t, recapChannel1.SourcePostIds, ch.SourcePostIds) + } else if ch.Id == recapChannel2.Id { + assert.Equal(t, recapChannel2.ChannelName, ch.ChannelName) + assert.Equal(t, recapChannel2.Highlights, ch.Highlights) + assert.Equal(t, recapChannel2.ActionItems, ch.ActionItems) + assert.Equal(t, recapChannel2.SourcePostIds, ch.SourcePostIds) + } + } + }) + + t.Run("DeleteRecap", func(t *testing.T) { + recap := &model.Recap{ + Id: model.NewId(), + UserId: model.NewId(), + Title: "Test Recap", + CreateAt: model.GetMillis(), + UpdateAt: model.GetMillis(), + DeleteAt: 0, + ReadAt: 0, + TotalMessageCount: 10, + Status: model.RecapStatusCompleted, + BotID: "test-bot-id", + } + + _, err := ss.Recap().SaveRecap(recap) + require.NoError(t, err) + + err = ss.Recap().DeleteRecap(recap.Id) + require.NoError(t, err) + + // Verify soft delete - should not appear in user's recaps + recaps, err := ss.Recap().GetRecapsForUser(recap.UserId, 0, 10) + require.NoError(t, err) + assert.Len(t, recaps, 0) + }) + }) +} diff --git a/server/channels/store/sqlstore/retention_policy_store.go b/server/channels/store/sqlstore/retention_policy_store.go index fbd41c1494d..b46434f59ff 100644 --- a/server/channels/store/sqlstore/retention_policy_store.go +++ b/server/channels/store/sqlstore/retention_policy_store.go @@ -5,7 +5,6 @@ package sqlstore import ( "database/sql" - "encoding/json" "fmt" "strconv" "strings" @@ -31,8 +30,7 @@ func newSqlRetentionPolicyStore(sqlStore *SqlStore, metrics einterfaces.MetricsI } } -// executePossiblyEmptyQuery only executes the query if it is non-empty. This helps avoid -// having to check for MySQL, which, unlike Postgres, does not allow empty queries. +// executePossiblyEmptyQuery only executes the query if it is non-empty. func executePossiblyEmptyQuery(txn *sqlxTxWrapper, query string, args ...any) (sql.Result, error) { if query == "" { return nil, nil @@ -641,15 +639,11 @@ func subQueryIN(property string, query sq.SelectBuilder) sq.Sqlizer { // DeleteOrphanedRows removes entries from RetentionPoliciesChannels and RetentionPoliciesTeams // where a channel or team no longer exists. func (s *SqlRetentionPolicyStore) DeleteOrphanedRows(limit int) (deleted int64, err error) { - // We need the extra level of nesting to deal with MySQL's locking - rpcSubQuery := sq.Select("ChannelId").FromSelect( - sq.Select("ChannelId"). - From("RetentionPoliciesChannels"). - LeftJoin("Channels ON RetentionPoliciesChannels.ChannelId = Channels.Id"). - Where("Channels.Id IS NULL"). - Limit(uint64(limit)), - "A", - ) + rpcSubQuery := sq.Select("ChannelId"). + From("RetentionPoliciesChannels"). + LeftJoin("Channels ON RetentionPoliciesChannels.ChannelId = Channels.Id"). + Where("Channels.Id IS NULL"). + Limit(uint64(limit)) rpcDeleteQuery, rpcArgs, err := s.getQueryBuilder(). Delete("RetentionPoliciesChannels"). @@ -659,15 +653,11 @@ func (s *SqlRetentionPolicyStore) DeleteOrphanedRows(limit int) (deleted int64, return int64(0), errors.Wrap(err, "retention_policies_channels_tosql") } - // We need the extra level of nesting to deal with MySQL's locking - rptSubQuery := sq.Select("TeamId").FromSelect( - sq.Select("TeamId"). - From("RetentionPoliciesTeams"). - LeftJoin("Teams ON RetentionPoliciesTeams.TeamId = Teams.Id"). - Where("Teams.Id IS NULL"). - Limit(uint64(limit)), - "A", - ) + rptSubQuery := sq.Select("TeamId"). + From("RetentionPoliciesTeams"). + LeftJoin("Teams ON RetentionPoliciesTeams.TeamId = Teams.Id"). + Where("Teams.Id IS NULL"). + Limit(uint64(limit)) rptDeleteQuery, rptArgs, err := s.getQueryBuilder(). Delete("RetentionPoliciesTeams"). @@ -817,26 +807,14 @@ func (s *SqlRetentionPolicyStore) GetChannelPoliciesCountForUser(userID string) return count, nil } -func scanRetentionIdsForDeletion(rows *sql.Rows, isPostgres bool) ([]*model.RetentionIdsForDeletion, error) { +func scanRetentionIdsForDeletion(rows *sql.Rows) ([]*model.RetentionIdsForDeletion, error) { idsForDeletion := []*model.RetentionIdsForDeletion{} for rows.Next() { var row model.RetentionIdsForDeletion - if isPostgres { - if err := rows.Scan( - &row.Id, &row.TableName, pq.Array(&row.Ids), - ); err != nil { - return nil, errors.Wrap(err, "unable to scan columns") - } - } else { - var ids []byte - if err := rows.Scan( - &row.Id, &row.TableName, &ids, - ); err != nil { - return nil, errors.Wrap(err, "unable to scan columns") - } - if err := json.Unmarshal(ids, &row.Ids); err != nil { - return nil, errors.Wrap(err, "failed to unmarshal ids") - } + if err := rows.Scan( + &row.Id, &row.TableName, pq.Array(&row.Ids), + ); err != nil { + return nil, errors.Wrap(err, "unable to scan columns") } idsForDeletion = append(idsForDeletion, &row) @@ -867,8 +845,7 @@ func (s *SqlRetentionPolicyStore) GetIdsForDeletionByTableName(tableName string, } defer rows.Close() - isPostgres := s.DriverName() == model.DatabaseDriverPostgres - idsForDeletion, err := scanRetentionIdsForDeletion(rows, isPostgres) + idsForDeletion, err := scanRetentionIdsForDeletion(rows) if err != nil { return nil, errors.Wrap(err, "failed to scan ids for deletion") } @@ -880,18 +857,8 @@ func insertRetentionIdsForDeletion(txn *sqlxTxWrapper, row *model.RetentionIdsFo row.PreSave() insertBuilder := s.getQueryBuilder(). Insert("RetentionIdsForDeletion"). - Columns("Id", "TableName", "Ids") - if s.DriverName() == model.DatabaseDriverPostgres { - insertBuilder = insertBuilder. - Values(row.Id, row.TableName, pq.Array(row.Ids)) - } else { - jsonIds, err := json.Marshal(row.Ids) - if err != nil { - return err - } - insertBuilder = insertBuilder. - Values(row.Id, row.TableName, jsonIds) - } + Columns("Id", "TableName", "Ids"). + Values(row.Id, row.TableName, pq.Array(row.Ids)) insertQuery, insertArgs, err := insertBuilder.ToSql() if err != nil { return err diff --git a/server/channels/store/sqlstore/schema_dump.go b/server/channels/store/sqlstore/schema_dump.go index 229d8bf7e7e..3e1a06664f0 100644 --- a/server/channels/store/sqlstore/schema_dump.go +++ b/server/channels/store/sqlstore/schema_dump.go @@ -15,12 +15,7 @@ import ( ) // GetSchemaDefinition dumps the database schema. -// Only Postgres is supported. func (ss *SqlStore) GetSchemaDefinition() (*model.SupportPacketDatabaseSchema, error) { - if ss.DriverName() != model.DatabaseDriverPostgres { - return nil, errors.New("schema dump is only supported for Postgres") - } - var schemaInfo model.SupportPacketDatabaseSchema var rErr *multierror.Error diff --git a/server/channels/store/sqlstore/session_store.go b/server/channels/store/sqlstore/session_store.go index 8b23d9d3a85..98e0e280d39 100644 --- a/server/channels/store/sqlstore/session_store.go +++ b/server/channels/store/sqlstore/session_store.go @@ -369,12 +369,7 @@ func (me SqlSessionStore) AnalyticsSessionCount() (int64, error) { } func (me SqlSessionStore) Cleanup(expiryTime int64, batchSize int64) error { - var query string - if me.DriverName() == model.DatabaseDriverPostgres { - query = "DELETE FROM Sessions WHERE Id IN (SELECT Id FROM Sessions WHERE ExpiresAt != 0 AND ? > ExpiresAt LIMIT ?)" - } else { - query = "DELETE FROM Sessions WHERE ExpiresAt != 0 AND ? > ExpiresAt LIMIT ?" - } + query := "DELETE FROM Sessions WHERE Id IN (SELECT Id FROM Sessions WHERE ExpiresAt != 0 AND ? > ExpiresAt LIMIT ?)" var rowsAffected int64 = 1 diff --git a/server/channels/store/sqlstore/shared_channel_store.go b/server/channels/store/sqlstore/shared_channel_store.go index 2a4f722fb1d..69ec6a196d2 100644 --- a/server/channels/store/sqlstore/shared_channel_store.go +++ b/server/channels/store/sqlstore/shared_channel_store.go @@ -4,7 +4,6 @@ package sqlstore import ( - "context" "database/sql" "fmt" "strings" @@ -825,30 +824,25 @@ func (s SqlSharedChannelStore) GetUsersForSync(filter model.GetUsersForSyncFilte // UpdateUserLastSyncAt updates the LastSyncAt timestamp for the specified SharedChannelUser. func (s SqlSharedChannelStore) UpdateUserLastSyncAt(userID string, channelID string, remoteID string) error { - // fetching the user first creates a minor race condition. This is mitigated by ensuring that the - // LastUpdateAt is only ever increased. Doing it this way avoids the update with join that has differing - // syntax between MySQL and Postgres which Squirrel cannot handle. It also allows us to return - // a proper error when trying to update for a non-existent user, which cannot be done by checking RowsAffected - // when doing updates; RowsAffected=0 when the LastUpdateAt doesn't change and is the same result if user doesn't - // exist. - user, err := s.stores.user.Get(context.Background(), userID) - if err != nil { - return err - } - - updateAt := max(user.UpdateAt, user.LastPictureUpdate) - + // Use UPDATE FROM with RETURNING to do this in a single query. The RETURNING clause lets us detect + // if the user doesn't exist (no rows returned). query := s.getQueryBuilder(). Update("SharedChannelUsers AS scu"). - Set("LastSyncAt", sq.Expr("GREATEST(scu.LastSyncAt, ?)", updateAt)). + Set("LastSyncAt", sq.Expr("GREATEST(scu.LastSyncAt, GREATEST(u.UpdateAt, u.LastPictureUpdate))")). + From("Users AS u"). + Where("u.Id = scu.UserId"). Where(sq.Eq{ "scu.UserId": userID, "scu.ChannelId": channelID, "scu.RemoteId": remoteID, - }) + }). + Suffix("RETURNING scu.UserId") - _, err = s.GetMaster().ExecBuilder(query) - if err != nil { + var returnedID string + if err := s.GetMaster().GetBuilder(&returnedID, query); err != nil { + if err == sql.ErrNoRows { + return store.NewErrNotFound("User", userID) + } return fmt.Errorf("failed to update LastSyncAt for SharedChannelUser with userId=%s, channelId=%s, remoteId=%s: %w", userID, channelID, remoteID, err) } diff --git a/server/channels/store/sqlstore/sqlx_wrapper.go b/server/channels/store/sqlstore/sqlx_wrapper.go index 7a9a4610858..6add61b8cc5 100644 --- a/server/channels/store/sqlstore/sqlx_wrapper.go +++ b/server/channels/store/sqlstore/sqlx_wrapper.go @@ -17,7 +17,6 @@ import ( "github.com/jmoiron/sqlx" - "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/v8/channels/store/storetest" sq "github.com/mattermost/squirrel" @@ -63,9 +62,7 @@ type sqlxExecutor interface { SelectBuilder(dest any, builder Builder) error } -// namedParamRegex is used to capture all named parameters and convert them -// to lowercase. This is necessary to be able to use a single query for both -// Postgres and MySQL. +// namedParamRegex is used to capture all named parameters and convert them to lowercase. // This will also lowercase any constant strings containing a :, but sqlx // will fail the query, so it won't be checked in inadvertently. var namedParamRegex = regexp.MustCompile(`:\w+`) @@ -134,9 +131,7 @@ func (w *sqlxDBWrapper) GetBuilder(dest any, builder Builder) error { } func (w *sqlxDBWrapper) NamedExec(query string, arg any) (sql.Result, error) { - if w.DB.DriverName() == model.DatabaseDriverPostgres { - query = namedParamRegex.ReplaceAllStringFunc(query, strings.ToLower) - } + query = namedParamRegex.ReplaceAllStringFunc(query, strings.ToLower) ctx, cancel := context.WithTimeout(context.Background(), w.queryTimeout) defer cancel() @@ -192,9 +187,7 @@ func (w *sqlxDBWrapper) ExecRaw(query string, args ...any) (sql.Result, error) { } func (w *sqlxDBWrapper) NamedQuery(query string, arg any) (*sqlx.Rows, error) { - if w.DB.DriverName() == model.DatabaseDriverPostgres { - query = namedParamRegex.ReplaceAllStringFunc(query, strings.ToLower) - } + query = namedParamRegex.ReplaceAllStringFunc(query, strings.ToLower) ctx, cancel := context.WithTimeout(context.Background(), w.queryTimeout) defer cancel() @@ -348,9 +341,7 @@ func (w *sqlxTxWrapper) ExecRaw(query string, args ...any) (sql.Result, error) { } func (w *sqlxTxWrapper) NamedExec(query string, arg any) (sql.Result, error) { - if w.Tx.DriverName() == model.DatabaseDriverPostgres { - query = namedParamRegex.ReplaceAllStringFunc(query, strings.ToLower) - } + query = namedParamRegex.ReplaceAllStringFunc(query, strings.ToLower) ctx, cancel := context.WithTimeout(context.Background(), w.queryTimeout) defer cancel() @@ -364,9 +355,7 @@ func (w *sqlxTxWrapper) NamedExec(query string, arg any) (sql.Result, error) { } func (w *sqlxTxWrapper) NamedQuery(query string, arg any) (*sqlx.Rows, error) { - if w.Tx.DriverName() == model.DatabaseDriverPostgres { - query = namedParamRegex.ReplaceAllStringFunc(query, strings.ToLower) - } + query = namedParamRegex.ReplaceAllStringFunc(query, strings.ToLower) ctx, cancel := context.WithTimeout(context.Background(), w.queryTimeout) defer cancel() diff --git a/server/channels/store/sqlstore/status_store.go b/server/channels/store/sqlstore/status_store.go index da4f0fb5812..1ab78794dc2 100644 --- a/server/channels/store/sqlstore/status_store.go +++ b/server/channels/store/sqlstore/status_store.go @@ -26,12 +26,11 @@ func newSqlStatusStore(sqlStore *SqlStore) store.StatusStore { SqlStore: sqlStore, } - manualColumnName := quoteColumnName(s.DriverName(), "Manual") s.statusSelectQuery = s.getQueryBuilder(). Select( "COALESCE(UserId, '') AS UserId", "COALESCE(Status, '') AS Status", - fmt.Sprintf("COALESCE(%s, FALSE) AS %s", manualColumnName, manualColumnName), + "COALESCE(Manual, FALSE) AS Manual", "COALESCE(LastActivityAt, 0) AS LastActivityAt", "COALESCE(DNDEndTime, 0) AS DNDEndTime", "COALESCE(PrevStatus, '') AS PrevStatus", @@ -44,7 +43,7 @@ func newSqlStatusStore(sqlStore *SqlStore) store.StatusStore { func (s SqlStatusStore) SaveOrUpdate(st *model.Status) error { query := s.getQueryBuilder(). Insert("Status"). - Columns("UserId", "Status", quoteColumnName(s.DriverName(), "Manual"), "LastActivityAt", "DNDEndTime", "PrevStatus"). + Columns("UserId", "Status", "Manual", "LastActivityAt", "DNDEndTime", "PrevStatus"). Values(st.UserId, st.Status, st.Manual, st.LastActivityAt, st.DNDEndTime, st.PrevStatus) query = query.SuffixExpr(sq.Expr("ON CONFLICT (userid) DO UPDATE SET Status = EXCLUDED.Status, Manual = EXCLUDED.Manual, LastActivityAt = EXCLUDED.LastActivityAt, DNDEndTime = EXCLUDED.DNDEndTime, PrevStatus = EXCLUDED.PrevStatus")) @@ -70,7 +69,7 @@ func (s SqlStatusStore) SaveOrUpdateMany(statuses map[string]*model.Status) erro query := s.getQueryBuilder(). Insert("Status"). - Columns("UserId", "Status", quoteColumnName(s.DriverName(), "Manual"), "LastActivityAt", "DNDEndTime", "PrevStatus") + Columns("UserId", "Status", "Manual", "LastActivityAt", "DNDEndTime", "PrevStatus") // Add values for each unique status for _, st := range statuses { @@ -125,7 +124,7 @@ func (s SqlStatusStore) UpdateExpiredDNDStatuses() (_ []*model.Status, err error Set("Status", sq.Expr("PrevStatus")). Set("PrevStatus", model.StatusDnd). Set("DNDEndTime", 0). - Set(quoteColumnName(s.DriverName(), "Manual"), false). + Set("Manual", false). Suffix("RETURNING *") statuses := []*model.Status{} @@ -138,7 +137,7 @@ func (s SqlStatusStore) UpdateExpiredDNDStatuses() (_ []*model.Status, err error } func (s SqlStatusStore) ResetAll() error { - if _, err := s.GetMaster().Exec(fmt.Sprintf("UPDATE Status SET Status = ? WHERE %s = false", quoteColumnName(s.DriverName(), "Manual")), model.StatusOffline); err != nil { + if _, err := s.GetMaster().Exec("UPDATE Status SET Status = ? WHERE Manual = false", model.StatusOffline); err != nil { return errors.Wrap(err, "failed to update Statuses") } return nil diff --git a/server/channels/store/sqlstore/store.go b/server/channels/store/sqlstore/store.go index 5ee1ab2ca84..79214f973a0 100644 --- a/server/channels/store/sqlstore/store.go +++ b/server/channels/store/sqlstore/store.go @@ -112,6 +112,7 @@ type SqlStoreStores struct { Attributes store.AttributesStore autotranslation store.AutoTranslationStore ContentFlagging store.ContentFlaggingStore + recap store.RecapStore readReceipt store.ReadReceiptStore temporaryPost store.TemporaryPostStore } @@ -265,6 +266,7 @@ func New(settings model.SqlSettings, logger mlog.LoggerIFace, metrics einterface store.stores.Attributes = newSqlAttributesStore(store, metrics) store.stores.autotranslation = newSqlAutoTranslationStore(store) store.stores.ContentFlagging = newContentFlaggingStore(store) + store.stores.recap = newSqlRecapStore(store) store.stores.readReceipt = newSqlReadReceiptStore(store, metrics) store.stores.temporaryPost = newSqlTemporaryPostStore(store, metrics) @@ -343,42 +345,23 @@ func (ss *SqlStore) DriverName() string { } // specialSearchChars have special meaning and can be treated as spaces -func (ss *SqlStore) specialSearchChars() []string { - chars := []string{ - "<", - ">", - "+", - "-", - "(", - ")", - "~", - ":", - } - - // Postgres can handle "@" without any errors - // Also helps postgres in enabling search for EmailAddresses - if ss.DriverName() != model.DatabaseDriverPostgres { - chars = append(chars, "@") - } - - return chars +var specialSearchChars = []string{ + "<", + ">", + "+", + "-", + "(", + ")", + "~", + ":", } // computeBinaryParam returns whether the data source uses binary_parameters -// when using Postgres func (ss *SqlStore) computeBinaryParam() (bool, error) { - if ss.DriverName() != model.DatabaseDriverPostgres { - return false, nil - } - return DSNHasBinaryParam(*ss.settings.DataSource) } func (ss *SqlStore) computeDefaultTextSearchConfig() (string, error) { - if ss.DriverName() != model.DatabaseDriverPostgres { - return "", nil - } - var defaultTextSearchConfig string err := ss.GetMaster().Get(&defaultTextSearchConfig, `SHOW default_text_search_config`) return defaultTextSearchConfig, err @@ -393,14 +376,10 @@ func (ss *SqlStore) IsBinaryParamEnabled() bool { // that can be parsed by callers. func (ss *SqlStore) GetDbVersion(numerical bool) (string, error) { var sqlVersion string - if ss.DriverName() == model.DatabaseDriverPostgres { - if numerical { - sqlVersion = `SHOW server_version_num` - } else { - sqlVersion = `SHOW server_version` - } + if numerical { + sqlVersion = `SHOW server_version_num` } else { - return "", errors.New("Not supported driver") + sqlVersion = `SHOW server_version` } var version string @@ -886,6 +865,10 @@ func (ss *SqlStore) AutoTranslation() store.AutoTranslationStore { return ss.stores.autotranslation } +func (ss *SqlStore) Recap() store.RecapStore { + return ss.stores.recap +} + func (ss *SqlStore) ReadReceipt() store.ReadReceiptStore { return ss.stores.readReceipt } @@ -946,19 +929,6 @@ func (ss *SqlStore) hasLicense() bool { return hasLicense } -func convertMySQLFullTextColumnsToPostgres(columnNames string) string { - columns := strings.Split(columnNames, ", ") - var concatenatedColumnNames strings.Builder - for i, c := range columns { - concatenatedColumnNames.WriteString(c) - if i < len(columns)-1 { - concatenatedColumnNames.WriteString(" || ' ' || ") - } - } - - return concatenatedColumnNames.String() -} - // IsDuplicate checks whether an error is a duplicate key error, which comes when processes are competing on creating the same // tables in the database. func IsDuplicate(err error) bool { @@ -975,15 +945,12 @@ func IsDuplicate(err error) bool { // ensureMinimumDBVersion gets the DB version and ensures it is // above the required minimum version requirements. func (ss *SqlStore) ensureMinimumDBVersion(ver string) (bool, error) { - switch *ss.settings.DriverName { - case model.DatabaseDriverPostgres: - intVer, err2 := strconv.Atoi(ver) - if err2 != nil { - return false, fmt.Errorf("cannot parse DB version: %v", err2) - } - if intVer < minimumRequiredPostgresVersion { - return false, fmt.Errorf("minimum Postgres version requirements not met. Found: %s, Wanted: %s", versionString(intVer, *ss.settings.DriverName), versionString(minimumRequiredPostgresVersion, *ss.settings.DriverName)) - } + intVer, err := strconv.Atoi(ver) + if err != nil { + return false, fmt.Errorf("cannot parse DB version: %v", err) + } + if intVer < minimumRequiredPostgresVersion { + return false, fmt.Errorf("minimum Postgres version requirements not met. Found: %s, Wanted: %s", versionString(intVer), versionString(minimumRequiredPostgresVersion)) } return true, nil } @@ -992,7 +959,7 @@ func (ss *SqlStore) ensureMinimumDBVersion(ver string) (bool, error) { // to a pretty-printed string. // Postgres doesn't follow three-part version numbers from 10.0 onwards: // https://www.postgresql.org/docs/13/libpq-status.html#LIBPQ-PQSERVERVERSION. -func versionString(v int, driver string) string { +func versionString(v int) string { minor := v % 10000 major := v / 10000 return strconv.Itoa(major) + "." + strconv.Itoa(minor) diff --git a/server/channels/store/sqlstore/store_test.go b/server/channels/store/sqlstore/store_test.go index c34bf2f79e7..a3be27e6ca3 100644 --- a/server/channels/store/sqlstore/store_test.go +++ b/server/channels/store/sqlstore/store_test.go @@ -763,28 +763,24 @@ func TestVersionString(t *testing.T) { versions := []struct { input int - driver string output string }{ { input: 100000, - driver: model.DatabaseDriverPostgres, output: "10.0", }, { input: 90603, - driver: model.DatabaseDriverPostgres, output: "9.603", }, { input: 120005, - driver: model.DatabaseDriverPostgres, output: "12.5", }, } for _, v := range versions { - out := versionString(v.input, v.driver) + out := versionString(v.input) assert.Equal(t, v.output, out) } } diff --git a/server/channels/store/sqlstore/team_store.go b/server/channels/store/sqlstore/team_store.go index 278fc6e66c1..28cb4726bfa 100644 --- a/server/channels/store/sqlstore/team_store.go +++ b/server/channels/store/sqlstore/team_store.go @@ -6,7 +6,6 @@ package sqlstore import ( "database/sql" "fmt" - "slices" "strings" sq "github.com/mattermost/squirrel" @@ -1614,19 +1613,11 @@ func (s SqlTeamStore) UserBelongsToTeams(userId string, teamIds []string) (bool, // UpdateMembersRole updates all the members of teamID in the adminIDs string array to be admins and sets all other // users as not being admin. -// It returns the list of userIDs whose roles got updated. -func (s SqlTeamStore) UpdateMembersRole(teamID string, adminIDs []string) (_ []*model.TeamMember, err error) { - transaction, err := s.GetMaster().Beginx() - if err != nil { - return nil, err - } - defer finalizeTransactionX(transaction, &err) - - // TODO: https://mattermost.atlassian.net/browse/MM-63368 - // On MySQL it's not possible to update a table and select from it in the same query. - // A SELECT and a UPDATE query are needed. - // Once we only support PostgreSQL, this can be done in a single query using RETURNING. - query, args, err := s.teamMembersQuery. +// It returns the list of members whose roles got updated. +func (s SqlTeamStore) UpdateMembersRole(teamID string, adminIDs []string) ([]*model.TeamMember, error) { + query := s.getQueryBuilder(). + Update("TeamMembers"). + Set("SchemeAdmin", sq.Case().When(sq.Eq{"UserId": adminIDs}, "true").Else("false")). Where(sq.Eq{"TeamId": teamID, "DeleteAt": 0}). Where(sq.Or{sq.Eq{"SchemeGuest": false}, sq.Expr("SchemeGuest IS NULL")}). Where( @@ -1642,42 +1633,14 @@ func (s SqlTeamStore) UpdateMembersRole(teamID string, adminIDs []string) (_ []* sq.NotEq{"UserId": adminIDs}, }, }, - ).ToSql() - if err != nil { - return nil, errors.Wrap(err, "team_tosql") - } + ). + Suffix("RETURNING " + strings.Join(teamMemberSliceColumns(), ", ")) var updatedMembers []*model.TeamMember - if err = transaction.Select(&updatedMembers, query, args...); err != nil { - return nil, errors.Wrap(err, "failed to get list of updated users") - } - - // Update SchemeAdmin field as the data from the SQL is not updated yet - for _, member := range updatedMembers { - if slices.Contains(adminIDs, member.UserId) { - member.SchemeAdmin = true - } else { - member.SchemeAdmin = false - } - } - - query, args, err = s.getQueryBuilder(). - Update("TeamMembers"). - Set("SchemeAdmin", sq.Case().When(sq.Eq{"UserId": adminIDs}, "true").Else("false")). - Where(sq.Eq{"TeamId": teamID, "DeleteAt": 0}). - Where(sq.Or{sq.Eq{"SchemeGuest": false}, sq.Expr("SchemeGuest IS NULL")}).ToSql() - if err != nil { - return nil, errors.Wrap(err, "team_tosql") - } - - if _, err = transaction.Exec(query, args...); err != nil { + if err := s.GetMaster().SelectBuilder(&updatedMembers, query); err != nil { return nil, errors.Wrap(err, "failed to update TeamMembers") } - if err = transaction.Commit(); err != nil { - return nil, errors.Wrap(err, "commit_transaction") - } - return updatedMembers, nil } diff --git a/server/channels/store/sqlstore/thread_store.go b/server/channels/store/sqlstore/thread_store.go index 9866560819f..4501eb5b279 100644 --- a/server/channels/store/sqlstore/thread_store.go +++ b/server/channels/store/sqlstore/thread_store.go @@ -5,7 +5,6 @@ package sqlstore import ( "database/sql" - "strconv" "time" sq "github.com/mattermost/squirrel" @@ -626,14 +625,8 @@ func (s *SqlThreadStore) MarkAllAsReadByChannels(userID string, channelIDs []str now := model.GetMillis() - var query sq.UpdateBuilder - if s.DriverName() == model.DatabaseDriverPostgres { - query = s.getQueryBuilder().Update("ThreadMemberships").From("Threads") - } else { - query = s.getQueryBuilder().Update("ThreadMemberships", "Threads") - } - - query = query.Set("LastViewed", now). + query := s.getQueryBuilder().Update("ThreadMemberships").From("Threads"). + Set("LastViewed", now). Set("UnreadMentions", 0). Set("LastUpdated", now). Where(sq.Eq{"ThreadMemberships.UserId": userID}). @@ -672,14 +665,7 @@ func (s *SqlThreadStore) MarkAllAsRead(userId string, threadIds []string) error func (s *SqlThreadStore) MarkAllAsReadByTeam(userId, teamId string) error { timestamp := model.GetMillis() - var query sq.UpdateBuilder - if s.DriverName() == model.DatabaseDriverPostgres { - query = s.getQueryBuilder().Update("ThreadMemberships").From("Threads") - } else { - query = s.getQueryBuilder().Update("ThreadMemberships", "Threads") - } - - query = query. + query := s.getQueryBuilder().Update("ThreadMemberships").From("Threads"). Where("Threads.PostId = ThreadMemberships.PostId"). Where(sq.Eq{"ThreadMemberships.UserId": userId}). Where(sq.Or{sq.Eq{"Threads.ThreadTeamId": teamId}, sq.Eq{"Threads.ThreadTeamId": ""}}). @@ -1111,30 +1097,19 @@ func (s *SqlThreadStore) SaveMultipleMemberships(memberships []*model.ThreadMemb } func (s *SqlThreadStore) updateThreadParticipantsForUserTx(trx *sqlxTxWrapper, postID, userID string) error { - if s.DriverName() == model.DatabaseDriverPostgres { - userIdParam, err := jsonArray([]string{userID}).Value() - if err != nil { - return err - } - if s.IsBinaryParamEnabled() { - userIdParam = AppendBinaryFlag(userIdParam.([]byte)) - } + userIdParam, err := jsonArray([]string{userID}).Value() + if err != nil { + return err + } + if s.IsBinaryParamEnabled() { + userIdParam = AppendBinaryFlag(userIdParam.([]byte)) + } - if _, err := trx.ExecRaw(`UPDATE Threads - SET participants = participants || $1::jsonb - WHERE postid=$2 - AND NOT participants ? $3`, userIdParam, postID, userID); err != nil { - return err - } - } else { - // CONCAT('$[', JSON_LENGTH(Participants), ']') just generates $[n] - // which is the positional syntax required for appending. - if _, err := trx.Exec(`UPDATE Threads - SET Participants = JSON_ARRAY_INSERT(Participants, CONCAT('$[', JSON_LENGTH(Participants), ']'), ?) - WHERE PostId=? - AND NOT JSON_CONTAINS(Participants, ?)`, userID, postID, strconv.Quote(userID)); err != nil { - return err - } + if _, err := trx.ExecRaw(`UPDATE Threads + SET participants = participants || $1::jsonb + WHERE postid=$2 + AND NOT participants ? $3`, userIdParam, postID, userID); err != nil { + return err } return nil diff --git a/server/channels/store/sqlstore/user_store.go b/server/channels/store/sqlstore/user_store.go index e7df2dfda8b..cf673b51c05 100644 --- a/server/channels/store/sqlstore/user_store.go +++ b/server/channels/store/sqlstore/user_store.go @@ -49,45 +49,58 @@ func (us *SqlUserStore) ClearCaches() {} func (us SqlUserStore) InvalidateProfileCacheForUser(userId string) {} -// getUsersColumns exposes the set of columns that can be queried from the -// Users table (and not the Bots table). +// getUsersColumnsWithName returns the user columns prefixed with the given name, +// with any extra columns appended. // +// Note that the order of these columns must match the order in +// [SqlUserStore.Get] and [SqlUserStore.GetAllProfilesInChannel]. +func getUsersColumnsWithName(name string, extraColumns ...string) []string { + columns := []string{ + "Id", + "CreateAt", + "UpdateAt", + "DeleteAt", + "Username", + "Password", + "AuthData", + "AuthService", + "Email", + "EmailVerified", + "Nickname", + "FirstName", + "LastName", + "Position", + "Roles", + "AllowMarketing", + "Props", + "NotifyProps", + "LastPasswordUpdate", + "LastPictureUpdate", + "FailedAttempts", + "Locale", + "Timezone", + "MfaActive", + "MfaSecret", + "MfaUsedTimestamps", + "RemoteId", + "LastLogin", + } + columns = append(columns, extraColumns...) + result := make([]string, len(columns)) + for i, col := range columns { + result[i] = name + "." + col + } + return result +} + +// getUsersColumns returns the user columns for the Users table. // This is primarily useful for other stores who choose to directly query // and return [model.User] data. // // Note that the order of these columns must match the order in // [SqlUserStore.Get] and [SqlUserStore.GetAllProfilesInChannel]. func getUsersColumns() []string { - return []string{ - "Users.Id", - "Users.CreateAt", - "Users.UpdateAt", - "Users.DeleteAt", - "Users.Username", - "Users.Password", - "Users.AuthData", - "Users.AuthService", - "Users.Email", - "Users.EmailVerified", - "Users.Nickname", - "Users.FirstName", - "Users.LastName", - "Users.Position", - "Users.Roles", - "Users.AllowMarketing", - "Users.Props", - "Users.NotifyProps", - "Users.LastPasswordUpdate", - "Users.LastPictureUpdate", - "Users.FailedAttempts", - "Users.Locale", - "Users.Timezone", - "Users.MfaActive", - "Users.MfaSecret", - "Users.MfaUsedTimestamps", - "Users.RemoteId", - "Users.LastLogin", - } + return getUsersColumnsWithName("Users") } func getBotInfoColumns() []string { @@ -627,8 +640,6 @@ func (us SqlUserStore) GetEtagForAllProfiles() string { } func (us SqlUserStore) GetAllProfiles(options *model.UserGetOptions) ([]*model.User, error) { - isPostgreSQL := us.DriverName() == model.DatabaseDriverPostgres - // Determine ordering based on Sort option - default to Username ASC for backwards compatibility orderBy := "Users.Username ASC" if options.Sort == "update_at_asc" { @@ -641,8 +652,8 @@ func (us SqlUserStore) GetAllProfiles(options *model.UserGetOptions) ([]*model.U query = applyViewRestrictionsFilter(query, options.ViewRestrictions, true) - query = applyRoleFilter(query, options.Role, isPostgreSQL) - query = applyMultiRoleFilters(query, options.Roles, []string{}, []string{}, isPostgreSQL) + query = applyRoleFilter(query, options.Role) + query = applyMultiRoleFilters(query, options.Roles, []string{}, []string{}) if options.Inactive { query = query.Where("Users.DeleteAt != 0") @@ -666,22 +677,16 @@ func (us SqlUserStore) GetAllProfiles(options *model.UserGetOptions) ([]*model.U return users, nil } -func applyRoleFilter(query sq.SelectBuilder, role string, isPostgreSQL bool) sq.SelectBuilder { +func applyRoleFilter(query sq.SelectBuilder, role string) sq.SelectBuilder { if role == "" { return query } - if isPostgreSQL { - roleParam := fmt.Sprintf("%%%s%%", sanitizeSearchTerm(role, "\\")) - return query.Where("Users.Roles LIKE LOWER(?)", roleParam) - } - - roleParam := fmt.Sprintf("%%%s%%", sanitizeSearchTerm(role, "*")) - - return query.Where("Users.Roles LIKE ? ESCAPE '*'", roleParam) + roleParam := fmt.Sprintf("%%%s%%", sanitizeSearchTerm(role, "\\")) + return query.Where("Users.Roles LIKE LOWER(?)", roleParam) } -func applyMultiRoleFilters(query sq.SelectBuilder, systemRoles []string, teamRoles []string, channelRoles []string, isPostgreSQL bool) sq.SelectBuilder { +func applyMultiRoleFilters(query sq.SelectBuilder, systemRoles []string, teamRoles []string, channelRoles []string) sq.SelectBuilder { sqOr := sq.Or{} if len(systemRoles) > 0 && systemRoles[0] != "" { @@ -693,11 +698,7 @@ func applyMultiRoleFilters(query sq.SelectBuilder, systemRoles []string, teamRol sqOr = append(sqOr, sq.Eq{"Users.Roles": role}) case model.SystemGuestRoleId, model.SystemAdminRoleId, model.SystemUserManagerRoleId, model.SystemReadOnlyAdminRoleId, model.SystemManagerRoleId: // If querying for any other roles search using a wildcard. - if isPostgreSQL { - sqOr = append(sqOr, sq.ILike{"Users.Roles": queryRole}) - } else { - sqOr = append(sqOr, sq.Like{"Users.Roles": queryRole}) - } + sqOr = append(sqOr, sq.ILike{"Users.Roles": queryRole}) } } } @@ -706,17 +707,9 @@ func applyMultiRoleFilters(query sq.SelectBuilder, systemRoles []string, teamRol for _, channelRole := range channelRoles { switch channelRole { case model.ChannelAdminRoleId: - if isPostgreSQL { - sqOr = append(sqOr, sq.And{sq.Eq{"cm.SchemeAdmin": true}, sq.NotILike{"Users.Roles": wildcardSearchTerm(model.SystemAdminRoleId)}}) - } else { - sqOr = append(sqOr, sq.And{sq.Eq{"cm.SchemeAdmin": true}, sq.NotLike{"Users.Roles": wildcardSearchTerm(model.SystemAdminRoleId)}}) - } + sqOr = append(sqOr, sq.And{sq.Eq{"cm.SchemeAdmin": true}, sq.NotILike{"Users.Roles": wildcardSearchTerm(model.SystemAdminRoleId)}}) case model.ChannelUserRoleId: - if isPostgreSQL { - sqOr = append(sqOr, sq.And{sq.Eq{"cm.SchemeUser": true}, sq.Eq{"cm.SchemeAdmin": false}, sq.NotILike{"Users.Roles": wildcardSearchTerm(model.SystemAdminRoleId)}}) - } else { - sqOr = append(sqOr, sq.And{sq.Eq{"cm.SchemeUser": true}, sq.Eq{"cm.SchemeAdmin": false}, sq.NotLike{"Users.Roles": wildcardSearchTerm(model.SystemAdminRoleId)}}) - } + sqOr = append(sqOr, sq.And{sq.Eq{"cm.SchemeUser": true}, sq.Eq{"cm.SchemeAdmin": false}, sq.NotILike{"Users.Roles": wildcardSearchTerm(model.SystemAdminRoleId)}}) case model.ChannelGuestRoleId: sqOr = append(sqOr, sq.Eq{"cm.SchemeGuest": true}) } @@ -727,17 +720,9 @@ func applyMultiRoleFilters(query sq.SelectBuilder, systemRoles []string, teamRol for _, teamRole := range teamRoles { switch teamRole { case model.TeamAdminRoleId: - if isPostgreSQL { - sqOr = append(sqOr, sq.And{sq.Eq{"tm.SchemeAdmin": true}, sq.NotILike{"Users.Roles": wildcardSearchTerm(model.SystemAdminRoleId)}}) - } else { - sqOr = append(sqOr, sq.And{sq.Eq{"tm.SchemeAdmin": true}, sq.NotLike{"Users.Roles": wildcardSearchTerm(model.SystemAdminRoleId)}}) - } + sqOr = append(sqOr, sq.And{sq.Eq{"tm.SchemeAdmin": true}, sq.NotILike{"Users.Roles": wildcardSearchTerm(model.SystemAdminRoleId)}}) case model.TeamUserRoleId: - if isPostgreSQL { - sqOr = append(sqOr, sq.And{sq.Eq{"tm.SchemeUser": true}, sq.Eq{"tm.SchemeAdmin": false}, sq.NotILike{"Users.Roles": wildcardSearchTerm(model.SystemAdminRoleId)}}) - } else { - sqOr = append(sqOr, sq.And{sq.Eq{"tm.SchemeUser": true}, sq.Eq{"tm.SchemeAdmin": false}, sq.NotLike{"Users.Roles": wildcardSearchTerm(model.SystemAdminRoleId)}}) - } + sqOr = append(sqOr, sq.And{sq.Eq{"tm.SchemeUser": true}, sq.Eq{"tm.SchemeAdmin": false}, sq.NotILike{"Users.Roles": wildcardSearchTerm(model.SystemAdminRoleId)}}) case model.TeamGuestRoleId: sqOr = append(sqOr, sq.Eq{"tm.SchemeGuest": true}) } @@ -808,7 +793,6 @@ func (us SqlUserStore) GetEtagForProfiles(teamId string) string { } func (us SqlUserStore) GetProfiles(options *model.UserGetOptions) ([]*model.User, error) { - isPostgreSQL := us.DriverName() == model.DatabaseDriverPostgres query := us.usersQuery. Join("TeamMembers tm ON ( tm.UserId = Users.Id AND tm.DeleteAt = 0 )"). Where("tm.TeamId = ?", options.InTeamId). @@ -817,8 +801,8 @@ func (us SqlUserStore) GetProfiles(options *model.UserGetOptions) ([]*model.User query = applyViewRestrictionsFilter(query, options.ViewRestrictions, true) - query = applyRoleFilter(query, options.Role, isPostgreSQL) - query = applyMultiRoleFilters(query, options.Roles, options.TeamRoles, options.ChannelRoles, isPostgreSQL) + query = applyRoleFilter(query, options.Role) + query = applyMultiRoleFilters(query, options.Roles, options.TeamRoles, options.ChannelRoles) if options.Inactive { query = query.Where("Users.DeleteAt != 0") @@ -855,7 +839,7 @@ func (us SqlUserStore) GetProfilesInChannel(options *model.UserGetOptions) ([]*m query = query.Where("Users.DeleteAt = 0") } - query = applyMultiRoleFilters(query, options.Roles, options.TeamRoles, options.ChannelRoles, us.DriverName() == model.DatabaseDriverPostgres) + query = applyMultiRoleFilters(query, options.Roles, options.TeamRoles, options.ChannelRoles) users := []*model.User{} if err := us.GetReplica().SelectBuilder(&users, query); err != nil { @@ -1017,7 +1001,6 @@ func (us SqlUserStore) GetProfilesNotInChannel(teamId string, channelId string, } func (us SqlUserStore) GetProfilesWithoutTeam(options *model.UserGetOptions) ([]*model.User, error) { - isPostgreSQL := us.DriverName() == model.DatabaseDriverPostgres query := us.usersQuery. Where(`( SELECT @@ -1033,7 +1016,7 @@ func (us SqlUserStore) GetProfilesWithoutTeam(options *model.UserGetOptions) ([] query = applyViewRestrictionsFilter(query, options.ViewRestrictions, true) - query = applyRoleFilter(query, options.Role, isPostgreSQL) + query = applyRoleFilter(query, options.Role) if options.Inactive { query = query.Where("Users.DeleteAt != 0") @@ -1435,17 +1418,12 @@ func (us SqlUserStore) Count(options model.UserCountOptions) (int64, error) { query = query.Where(sq.Or{sq.Eq{"Users.RemoteId": ""}, sq.Eq{"Users.RemoteId": nil}}) } - isPostgreSQL := us.DriverName() == model.DatabaseDriverPostgres if options.IncludeBotAccounts { if options.ExcludeRegularUsers { query = query.Join("Bots ON Users.Id = Bots.UserId") } } else { - if isPostgreSQL { - query = query.LeftJoin("Bots ON Users.Id = Bots.UserId").Where("Bots.UserId IS NULL") - } else { - query = query.Where(sq.Expr("Users.Id NOT IN (SELECT UserId FROM Bots)")) - } + query = query.LeftJoin("Bots ON Users.Id = Bots.UserId").Where("Bots.UserId IS NULL") if options.ExcludeRegularUsers { // Currently this doesn't make sense because it will always return 0 @@ -1459,11 +1437,9 @@ func (us SqlUserStore) Count(options model.UserCountOptions) (int64, error) { query = query.LeftJoin("ChannelMembers AS cm ON Users.Id = cm.UserId").Where("cm.ChannelId = ?", options.ChannelId) } query = applyViewRestrictionsFilter(query, options.ViewRestrictions, false) - query = applyMultiRoleFilters(query, options.Roles, options.TeamRoles, options.ChannelRoles, isPostgreSQL) + query = applyMultiRoleFilters(query, options.Roles, options.TeamRoles, options.ChannelRoles) - if isPostgreSQL { - query = query.PlaceholderFormat(sq.Dollar) - } + query = query.PlaceholderFormat(sq.Dollar) queryString, args, err := query.ToSql() if err != nil { @@ -1483,11 +1459,7 @@ func (us SqlUserStore) AnalyticsActiveCount(timePeriod int64, options model.User query := us.getQueryBuilder().Select("COUNT(*)").From("Status AS s").Where("LastActivityAt > ?", time) if !options.IncludeBotAccounts { - if us.DriverName() == model.DatabaseDriverPostgres { - query = query.LeftJoin("Bots ON s.UserId = Bots.UserId").Where("Bots.UserId IS NULL") - } else { - query = query.Where(sq.Expr("UserId NOT IN (SELECT UserId FROM Bots)")) - } + query = query.LeftJoin("Bots ON s.UserId = Bots.UserId").Where("Bots.UserId IS NULL") } if !options.IncludeRemoteUsers || !options.IncludeDeleted { @@ -1519,11 +1491,7 @@ func (us SqlUserStore) AnalyticsActiveCountForPeriod(startTime int64, endTime in query := us.getQueryBuilder().Select("COUNT(*)").From("Status AS s").Where("LastActivityAt > ? AND LastActivityAt <= ?", startTime, endTime) if !options.IncludeBotAccounts { - if us.DriverName() == model.DatabaseDriverPostgres { - query = query.LeftJoin("Bots ON s.UserId = Bots.UserId").Where("Bots.UserId IS NULL") - } else { - query = query.Where(sq.Expr("UserId NOT IN (SELECT UserId FROM Bots)")) - } + query = query.LeftJoin("Bots ON s.UserId = Bots.UserId").Where("Bots.UserId IS NULL") } if !options.IncludeRemoteUsers || !options.IncludeDeleted { @@ -1722,16 +1690,12 @@ func (us SqlUserStore) SearchNotInGroup(groupID string, term string, options *mo return us.performSearch(query, term, options) } -func generateSearchQuery(query sq.SelectBuilder, terms []string, fields []string, isPostgreSQL bool) sq.SelectBuilder { +func generateSearchQuery(query sq.SelectBuilder, terms []string, fields []string) sq.SelectBuilder { for _, term := range terms { searchFields := []string{} termArgs := []any{} for _, field := range fields { - if isPostgreSQL { - searchFields = append(searchFields, fmt.Sprintf("lower(%s) LIKE lower(?) escape '*' ", field)) - } else { - searchFields = append(searchFields, fmt.Sprintf("%s LIKE ? escape '*' ", field)) - } + searchFields = append(searchFields, fmt.Sprintf("lower(%s) LIKE lower(?) escape '*' ", field)) termArgs = append(termArgs, fmt.Sprintf("%%%s%%", strings.TrimLeft(term, "@"))) } searchFields = append(searchFields, "Id = ?") @@ -1760,17 +1724,15 @@ func (us SqlUserStore) performSearch(query sq.SelectBuilder, term string, option } } - isPostgreSQL := us.DriverName() == model.DatabaseDriverPostgres - - query = applyRoleFilter(query, options.Role, isPostgreSQL) - query = applyMultiRoleFilters(query, options.Roles, options.TeamRoles, options.ChannelRoles, isPostgreSQL) + query = applyRoleFilter(query, options.Role) + query = applyMultiRoleFilters(query, options.Roles, options.TeamRoles, options.ChannelRoles) if !options.AllowInactive { query = query.Where("Users.DeleteAt = 0") } if strings.TrimSpace(term) != "" { - query = generateSearchQuery(query, strings.Fields(term), searchType, isPostgreSQL) + query = generateSearchQuery(query, strings.Fields(term), searchType) } query = applyViewRestrictionsFilter(query, options.ViewRestrictions, true) @@ -1794,19 +1756,12 @@ func (us SqlUserStore) performSearch(query sq.SelectBuilder, term string, option func (us SqlUserStore) AnalyticsGetInactiveUsersCount() (int64, error) { query := us.getQueryBuilder(). Select("COUNT(Id)"). - From("Users") - if us.DriverName() == model.DatabaseDriverPostgres { - query = query.LeftJoin("Bots ON Users.ID = Bots.UserId"). - Where(sq.And{ - sq.Gt{"Users.DeleteAt": 0}, - sq.Eq{"Bots.UserId": nil}, - }) - } else { - query = query.Where(sq.And{ - sq.Expr("Users.Id NOT IN (SELECT UserId FROM Bots)"), + From("Users"). + LeftJoin("Bots ON Users.ID = Bots.UserId"). + Where(sq.And{ sq.Gt{"Users.DeleteAt": 0}, + sq.Eq{"Bots.UserId": nil}, }) - } var count int64 err := us.GetReplica().GetBuilder(&count, query) @@ -2341,11 +2296,7 @@ func (us SqlUserStore) IsEmpty(excludeBots bool) (bool, error) { From("Users") if excludeBots { - if us.DriverName() == model.DatabaseDriverPostgres { - builder = builder.LeftJoin("Bots ON Users.Id = Bots.UserId").Where("Bots.UserId IS NULL") - } else { - builder = builder.Where(sq.Expr("Users.Id NOT IN (SELECT UserId FROM Bots)")) - } + builder = builder.LeftJoin("Bots ON Users.Id = Bots.UserId").Where("Bots.UserId IS NULL") } builder = builder.Suffix(")") @@ -2396,19 +2347,14 @@ func (us SqlUserStore) GetUsersWithInvalidEmails(page int, perPage int, restrict } func (us SqlUserStore) RefreshPostStatsForUsers() error { - if us.DriverName() == model.DatabaseDriverPostgres { - if _, err := us.GetMaster().Exec("REFRESH MATERIALIZED VIEW poststats"); err != nil { - return errors.Wrap(err, "users_refresh_post_stats_exec") - } - } else { - mlog.Debug("Skipped running refresh post stats, only available on Postgres") + if _, err := us.GetMaster().Exec("REFRESH MATERIALIZED VIEW poststats"); err != nil { + return errors.Wrap(err, "users_refresh_post_stats_exec") } - return nil } -func applyUserReportFilter(query sq.SelectBuilder, filter *model.UserReportOptions, isPostgres bool) sq.SelectBuilder { - query = applyRoleFilter(query, filter.Role, isPostgres) +func applyUserReportFilter(query sq.SelectBuilder, filter *model.UserReportOptions) sq.SelectBuilder { + query = applyRoleFilter(query, filter.Role) if filter.HasNoTeam { query = query.Where(sq.Expr("Users.Id NOT IN (SELECT UserId FROM TeamMembers WHERE DeleteAt = 0)")) } else if filter.Team != "" { @@ -2423,25 +2369,20 @@ func applyUserReportFilter(query sq.SelectBuilder, filter *model.UserReportOptio } if strings.TrimSpace(filter.SearchTerm) != "" { - query = generateSearchQuery(query, strings.Fields(sanitizeSearchTerm(filter.SearchTerm, "*")), UserSearchTypeAll, isPostgres) + query = generateSearchQuery(query, strings.Fields(sanitizeSearchTerm(filter.SearchTerm, "*")), UserSearchTypeAll) } return query } func (us SqlUserStore) GetUserCountForReport(filter *model.UserReportOptions) (int64, error) { - isPostgres := us.DriverName() == model.DatabaseDriverPostgres query := us.getQueryBuilder(). Select("COUNT(Users.Id)"). - From("Users") + From("Users"). + LeftJoin("Bots ON Users.Id = Bots.UserId"). + Where("Bots.UserId IS NULL") - if isPostgres { - query = query.LeftJoin("Bots ON Users.Id = Bots.UserId").Where("Bots.UserId IS NULL") - } else { - query = query.Where(sq.Expr("Users.Id NOT IN (SELECT UserId FROM Bots)")) - } - - query = applyUserReportFilter(query, filter, isPostgres) + query = applyUserReportFilter(query, filter) queryStr, args, err := query.ToSql() if err != nil { return 0, errors.Wrap(err, "user_count_report_tosql") @@ -2455,15 +2396,12 @@ func (us SqlUserStore) GetUserCountForReport(filter *model.UserReportOptions) (i } func (us SqlUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model.UserReportQuery, error) { - isPostgres := us.DriverName() == model.DatabaseDriverPostgres - selectColumns := append(getUsersColumns(), "MAX(s.LastActivityAt) AS LastStatusAt") - if isPostgres { - selectColumns = append(selectColumns, - "MAX(ps.LastPostDate) AS LastPostDate", - "COUNT(ps.Day) AS DaysActive", - "SUM(ps.NumPosts) AS TotalPosts", - ) - } + selectColumns := append(getUsersColumns(), + "MAX(s.LastActivityAt) AS LastStatusAt", + "MAX(ps.LastPostDate) AS LastPostDate", + "COUNT(ps.Day) AS DaysActive", + "SUM(ps.NumPosts) AS TotalPosts", + ) sortDirection := "ASC" if filter.SortDesc { @@ -2509,24 +2447,22 @@ func (us SqlUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model. query = query.Limit(uint64(filter.PageSize)) } - if isPostgres { - joinSql := sq.And{} - if filter.StartAt > 0 { - startDate := time.UnixMilli(filter.StartAt) - joinSql = append(joinSql, sq.GtOrEq{"ps.Day": startDate.Format("2006-01-02")}) - } - if filter.EndAt > 0 { - endDate := time.UnixMilli(filter.EndAt) - joinSql = append(joinSql, sq.Lt{"ps.Day": endDate.Format("2006-01-02")}) - } - sql, args, err := joinSql.ToSql() - if err != nil { - return nil, err - } - query = query.LeftJoin("PostStats ps ON ps.UserId = Users.Id AND "+sql, args...) + joinSql := sq.And{} + if filter.StartAt > 0 { + startDate := time.UnixMilli(filter.StartAt) + joinSql = append(joinSql, sq.GtOrEq{"ps.Day": startDate.Format("2006-01-02")}) } + if filter.EndAt > 0 { + endDate := time.UnixMilli(filter.EndAt) + joinSql = append(joinSql, sq.Lt{"ps.Day": endDate.Format("2006-01-02")}) + } + sql, args, err := joinSql.ToSql() + if err != nil { + return nil, err + } + query = query.LeftJoin("PostStats ps ON ps.UserId = Users.Id AND "+sql, args...) - query = applyUserReportFilter(query, filter, isPostgres) + query = applyUserReportFilter(query, filter) parentQuery := query // If we're going a page back... @@ -2541,13 +2477,13 @@ func (us SqlUserStore) GetUserReport(filter *model.UserReportOptions) ([]*model. } parentQuery = us.getQueryBuilder(). - Select("data.*"). + Select(getUsersColumnsWithName("data", "LastStatusAt", "LastPostDate", "DaysActive", "TotalPosts")...). FromSelect(query, "data"). OrderBy(filter.SortColumn+" "+reverseSortDirection, "Id") } userResults := []*model.UserReportQuery{} - err := us.GetReplica().SelectBuilder(&userResults, parentQuery) + err = us.GetReplica().SelectBuilder(&userResults, parentQuery) if err != nil { return nil, errors.Wrap(err, "failed to get users for reporting") } diff --git a/server/channels/store/sqlstore/utils.go b/server/channels/store/sqlstore/utils.go index 72a6ce5452d..c1064af7f5a 100644 --- a/server/channels/store/sqlstore/utils.go +++ b/server/channels/store/sqlstore/utils.go @@ -159,11 +159,6 @@ func trimInput(input string) string { return input } -// Returns the column name for PostgreSQL. -func quoteColumnName(driver string, columnName string) string { - return columnName -} - // scanRowsIntoMap scans SQL rows into a map, using a provided scanner function to extract key-value pairs func scanRowsIntoMap[K comparable, V any](rows *sql.Rows, scanner func(rows *sql.Rows) (K, V, error), defaults map[K]V) (map[K]V, error) { results := make(map[K]V, len(defaults)) diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 93bea724f50..28410f62a49 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -98,6 +98,7 @@ type Store interface { AutoTranslation() AutoTranslationStore GetSchemaDefinition() (*model.SupportPacketDatabaseSchema, error) ContentFlagging() ContentFlaggingStore + Recap() RecapStore ReadReceipt() ReadReceiptStore TemporaryPost() TemporaryPostStore } @@ -1291,3 +1292,16 @@ type ThreadMembershipImportData struct { // UnreadMentions is the number of unread mentions to set the UnreadMentions field to. UnreadMentions int64 } + +type RecapStore interface { + SaveRecap(recap *model.Recap) (*model.Recap, error) + UpdateRecap(recap *model.Recap) (*model.Recap, error) + GetRecap(id string) (*model.Recap, error) + GetRecapsForUser(userId string, page, perPage int) ([]*model.Recap, error) + UpdateRecapStatus(id, status string) error + MarkRecapAsRead(id string) error + DeleteRecap(id string) error + DeleteRecapChannels(recapId string) error + SaveRecapChannel(recapChannel *model.RecapChannel) error + GetRecapChannelsByRecapId(recapId string) ([]*model.RecapChannel, error) +} diff --git a/server/channels/store/storetest/channel_store.go b/server/channels/store/storetest/channel_store.go index a733d02ad36..7631e03842b 100644 --- a/server/channels/store/storetest/channel_store.go +++ b/server/channels/store/storetest/channel_store.go @@ -1019,9 +1019,7 @@ func testChannelStoreGetByNames(t *testing.T, rctx request.CTX, ss store.Store) for _, channel := range channels { ids = append(ids, channel.Id) } - sort.Strings(ids) - sort.Strings(tc.ExpectedIds) - assert.Equal(t, tc.ExpectedIds, ids, "tc %v", index) + assert.ElementsMatch(t, tc.ExpectedIds, ids, "tc %v", index) } err := ss.Channel().Delete(o1.Id, model.GetMillis()) @@ -1076,9 +1074,7 @@ func testChannelStoreGetByNamesIncludeDeleted(t *testing.T, rctx request.CTX, ss for _, channel := range channels { ids = append(ids, channel.Id) } - sort.Strings(ids) - sort.Strings(tc.ExpectedIds) - assert.Equal(t, tc.ExpectedIds, ids, "tc %v", index) + assert.ElementsMatch(t, tc.ExpectedIds, ids, "tc %v", index) } } @@ -5994,21 +5990,6 @@ func testChannelStoreSearchMore(t *testing.T, rctx request.CTX, ss store.Store) }) } -type ByChannelDisplayName model.ChannelList - -func (s ByChannelDisplayName) Len() int { return len(s) } -func (s ByChannelDisplayName) Swap(i, j int) { - s[i], s[j] = s[j], s[i] -} - -func (s ByChannelDisplayName) Less(i, j int) bool { - if s[i].DisplayName != s[j].DisplayName { - return s[i].DisplayName < s[j].DisplayName - } - - return s[i].Id < s[j].Id -} - func testChannelStoreSearchInTeam(t *testing.T, rctx request.CTX, ss store.Store) { teamID := model.NewId() otherTeamID := model.NewId() @@ -6222,8 +6203,7 @@ func testChannelStoreSearchInTeam(t *testing.T, rctx request.CTX, ss store.Store t.Run("AutoCompleteInTeam/"+testCase.Description, func(t *testing.T) { channels, err := ss.Channel().AutocompleteInTeam(rctx, testCase.TeamID, testCase.UserID, testCase.Term, testCase.IncludeDeleted, false) require.NoError(t, err) - sort.Sort(ByChannelDisplayName(channels)) - require.Equal(t, testCase.ExpectedResults, channels) + require.ElementsMatch(t, testCase.ExpectedResults, channels) }) } } @@ -6421,6 +6401,24 @@ func testAutocomplete(t *testing.T, rctx request.CTX, ss store.Store, s SqlStore }) } + // MM-67049: Verify that users removed from a team cannot see channels from that + // team, regardless of includeDeleted. The includeDeleted parameter should only + // affect channel deletion status, not team membership. + t.Run("MM-67049: removed team member cannot see channels regardless of includeDeleted", func(t *testing.T) { + // Sanity check: o5 is in leftTeamID and matches search term + require.Equal(t, leftTeamID, o5.TeamId) + require.Contains(t, o5.DisplayName, "ChannelA") + + // m1.UserId was removed from leftTeamID (tm5.DeleteAt was set above in the test setup) + for _, includeDeleted := range []bool{false, true} { + channels, err2 := ss.Channel().Autocomplete(rctx, m1.UserId, "ChannelA", includeDeleted, false) + require.NoError(t, err2) + for _, ch := range channels { + require.NotEqual(t, o5.Id, ch.Id, "includeDeleted=%v: channel from left team should not be returned", includeDeleted) + } + } + }) + t.Run("Limit", func(t *testing.T) { for i := range model.ChannelSearchDefaultLimit + 10 { _, err = ss.Channel().Save(rctx, &model.Channel{ diff --git a/server/channels/store/storetest/file_info_store.go b/server/channels/store/storetest/file_info_store.go index c7850b570dc..72c315b3947 100644 --- a/server/channels/store/storetest/file_info_store.go +++ b/server/channels/store/storetest/file_info_store.go @@ -5,7 +5,6 @@ package storetest import ( "fmt" - "sort" "testing" "time" @@ -422,12 +421,6 @@ func testFileInfoGetWithOptions(t *testing.T, rctx request.CTX, ss store.Store) } } -type byFileInfoID []*model.FileInfo - -func (a byFileInfoID) Len() int { return len(a) } -func (a byFileInfoID) Swap(i, j int) { a[i], a[j] = a[j], a[i] } -func (a byFileInfoID) Less(i, j int) bool { return a[i].Id < a[j].Id } - func testFileInfoAttachToPost(t *testing.T, rctx request.CTX, ss store.Store) { t.Run("should attach files", func(t *testing.T) { userID := model.NewId() @@ -462,9 +455,7 @@ func testFileInfoAttachToPost(t *testing.T, rctx request.CTX, ss store.Store) { require.NoError(t, err) expected := []*model.FileInfo{info1, info2} - sort.Sort(byFileInfoID(expected)) - sort.Sort(byFileInfoID(data)) - assert.EqualValues(t, expected, data) + assert.ElementsMatch(t, expected, data) }) t.Run("should not attach files to multiple posts", func(t *testing.T) { diff --git a/server/channels/store/storetest/mocks/RecapStore.go b/server/channels/store/storetest/mocks/RecapStore.go new file mode 100644 index 00000000000..f74c42b972b --- /dev/null +++ b/server/channels/store/storetest/mocks/RecapStore.go @@ -0,0 +1,269 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +// Regenerate this file using `make store-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost/server/public/model" + mock "github.com/stretchr/testify/mock" +) + +// RecapStore is an autogenerated mock type for the RecapStore type +type RecapStore struct { + mock.Mock +} + +// DeleteRecap provides a mock function with given fields: id +func (_m *RecapStore) DeleteRecap(id string) error { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for DeleteRecap") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteRecapChannels provides a mock function with given fields: recapId +func (_m *RecapStore) DeleteRecapChannels(recapId string) error { + ret := _m.Called(recapId) + + if len(ret) == 0 { + panic("no return value specified for DeleteRecapChannels") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(recapId) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetRecap provides a mock function with given fields: id +func (_m *RecapStore) GetRecap(id string) (*model.Recap, error) { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for GetRecap") + } + + var r0 *model.Recap + var r1 error + if rf, ok := ret.Get(0).(func(string) (*model.Recap, error)); ok { + return rf(id) + } + if rf, ok := ret.Get(0).(func(string) *model.Recap); ok { + r0 = rf(id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Recap) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetRecapChannelsByRecapId provides a mock function with given fields: recapId +func (_m *RecapStore) GetRecapChannelsByRecapId(recapId string) ([]*model.RecapChannel, error) { + ret := _m.Called(recapId) + + if len(ret) == 0 { + panic("no return value specified for GetRecapChannelsByRecapId") + } + + var r0 []*model.RecapChannel + var r1 error + if rf, ok := ret.Get(0).(func(string) ([]*model.RecapChannel, error)); ok { + return rf(recapId) + } + if rf, ok := ret.Get(0).(func(string) []*model.RecapChannel); ok { + r0 = rf(recapId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.RecapChannel) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(recapId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetRecapsForUser provides a mock function with given fields: userId, page, perPage +func (_m *RecapStore) GetRecapsForUser(userId string, page int, perPage int) ([]*model.Recap, error) { + ret := _m.Called(userId, page, perPage) + + if len(ret) == 0 { + panic("no return value specified for GetRecapsForUser") + } + + var r0 []*model.Recap + var r1 error + if rf, ok := ret.Get(0).(func(string, int, int) ([]*model.Recap, error)); ok { + return rf(userId, page, perPage) + } + if rf, ok := ret.Get(0).(func(string, int, int) []*model.Recap); ok { + r0 = rf(userId, page, perPage) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.Recap) + } + } + + if rf, ok := ret.Get(1).(func(string, int, int) error); ok { + r1 = rf(userId, page, perPage) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MarkRecapAsRead provides a mock function with given fields: id +func (_m *RecapStore) MarkRecapAsRead(id string) error { + ret := _m.Called(id) + + if len(ret) == 0 { + panic("no return value specified for MarkRecapAsRead") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SaveRecap provides a mock function with given fields: recap +func (_m *RecapStore) SaveRecap(recap *model.Recap) (*model.Recap, error) { + ret := _m.Called(recap) + + if len(ret) == 0 { + panic("no return value specified for SaveRecap") + } + + var r0 *model.Recap + var r1 error + if rf, ok := ret.Get(0).(func(*model.Recap) (*model.Recap, error)); ok { + return rf(recap) + } + if rf, ok := ret.Get(0).(func(*model.Recap) *model.Recap); ok { + r0 = rf(recap) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Recap) + } + } + + if rf, ok := ret.Get(1).(func(*model.Recap) error); ok { + r1 = rf(recap) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SaveRecapChannel provides a mock function with given fields: recapChannel +func (_m *RecapStore) SaveRecapChannel(recapChannel *model.RecapChannel) error { + ret := _m.Called(recapChannel) + + if len(ret) == 0 { + panic("no return value specified for SaveRecapChannel") + } + + var r0 error + if rf, ok := ret.Get(0).(func(*model.RecapChannel) error); ok { + r0 = rf(recapChannel) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdateRecap provides a mock function with given fields: recap +func (_m *RecapStore) UpdateRecap(recap *model.Recap) (*model.Recap, error) { + ret := _m.Called(recap) + + if len(ret) == 0 { + panic("no return value specified for UpdateRecap") + } + + var r0 *model.Recap + var r1 error + if rf, ok := ret.Get(0).(func(*model.Recap) (*model.Recap, error)); ok { + return rf(recap) + } + if rf, ok := ret.Get(0).(func(*model.Recap) *model.Recap); ok { + r0 = rf(recap) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Recap) + } + } + + if rf, ok := ret.Get(1).(func(*model.Recap) error); ok { + r1 = rf(recap) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// UpdateRecapStatus provides a mock function with given fields: id, status +func (_m *RecapStore) UpdateRecapStatus(id string, status string) error { + ret := _m.Called(id, status) + + if len(ret) == 0 { + panic("no return value specified for UpdateRecapStatus") + } + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(id, status) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewRecapStore creates a new instance of RecapStore. 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 NewRecapStore(t interface { + mock.TestingT + Cleanup(func()) +}) *RecapStore { + mock := &RecapStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/channels/store/storetest/mocks/Store.go b/server/channels/store/storetest/mocks/Store.go index c55bc2a7f2e..8830725e6a2 100644 --- a/server/channels/store/storetest/mocks/Store.go +++ b/server/channels/store/storetest/mocks/Store.go @@ -966,6 +966,26 @@ func (_m *Store) Reaction() store.ReactionStore { return r0 } +// Recap provides a mock function with no fields +func (_m *Store) Recap() store.RecapStore { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Recap") + } + + var r0 store.RecapStore + if rf, ok := ret.Get(0).(func() store.RecapStore); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(store.RecapStore) + } + } + + return r0 +} + // ReadReceipt provides a mock function with no fields func (_m *Store) ReadReceipt() store.ReadReceiptStore { ret := _m.Called() diff --git a/server/channels/store/storetest/plugin_store.go b/server/channels/store/storetest/plugin_store.go index 5daf987496f..ab4abe0befd 100644 --- a/server/channels/store/storetest/plugin_store.go +++ b/server/channels/store/storetest/plugin_store.go @@ -4,7 +4,6 @@ package storetest import ( - "sort" "testing" "github.com/stretchr/testify/assert" @@ -1262,7 +1261,6 @@ func testPluginList(t *testing.T, rctx request.CTX, ss store.Store) { keys = append(keys, key) } - sort.Strings(keys) keys1, err := ss.Plugin().List(pluginID, 0, 100) require.NoError(t, err) @@ -1273,9 +1271,7 @@ func testPluginList(t *testing.T, rctx request.CTX, ss store.Store) { require.Len(t, keys2, 50) actualKeys := append(keys1, keys2...) - sort.Strings(actualKeys) - - assert.Equal(t, keys, actualKeys) + assert.ElementsMatch(t, keys, actualKeys) }) t.Run("multiple keys, some expiring", func(t *testing.T) { @@ -1312,7 +1308,6 @@ func testPluginList(t *testing.T, rctx request.CTX, ss store.Store) { keys = append(keys, key) } } - sort.Strings(keys) keys1, err := ss.Plugin().List(pluginID, 0, 100) require.NoError(t, err) @@ -1323,9 +1318,7 @@ func testPluginList(t *testing.T, rctx request.CTX, ss store.Store) { require.Len(t, keys2, 35) actualKeys := append(keys1, keys2...) - sort.Strings(actualKeys) - - assert.Equal(t, keys, actualKeys) + assert.ElementsMatch(t, keys, actualKeys) }) t.Run("offsets and limits", func(t *testing.T) { @@ -1335,21 +1328,16 @@ func testPluginList(t *testing.T, rctx request.CTX, ss store.Store) { // Ignore the pluginID setup by setupKVs pluginID := model.NewId() - var keys []string for range 150 { - key := model.NewId() kv := &model.PluginKeyValue{ PluginId: pluginID, - Key: key, + Key: model.NewId(), Value: []byte(model.NewId()), ExpireAt: 0, } _, err := ss.Plugin().SaveOrUpdate(kv) require.NoError(t, err) - - keys = append(keys, key) } - sort.Strings(keys) t.Run("default limit", func(t *testing.T) { keys1, err := ss.Plugin().List(pluginID, 0, 0) diff --git a/server/channels/store/storetest/post_store.go b/server/channels/store/storetest/post_store.go index 7855e55f52f..4596dddca72 100644 --- a/server/channels/store/storetest/post_store.go +++ b/server/channels/store/storetest/post_store.go @@ -5973,8 +5973,7 @@ func testGetPostsForReporting(t *testing.T, rctx request.CTX, ss store.Store, s // // For reporting queries, we expect the query to use index seeks, not table scans // - // Note: The actual query plan depends on the database (PostgreSQL vs MySQL), - // data distribution, and statistics. This test just verifies the query executes + // Note: The actual query plan depends on data distribution and statistics. This test just verifies the query executes // efficiently by checking that it completes in a reasonable time. // Create a larger dataset to better test index usage diff --git a/server/channels/store/storetest/retention_policy_store.go b/server/channels/store/storetest/retention_policy_store.go index d15e8a81c35..8e0d60456e0 100644 --- a/server/channels/store/storetest/retention_policy_store.go +++ b/server/channels/store/storetest/retention_policy_store.go @@ -4,7 +4,6 @@ package storetest import ( - "sort" "strconv" "testing" @@ -60,26 +59,8 @@ func CheckRetentionPolicyWithTeamAndChannelIdsAreEqual(t *testing.T, p1, p2 *mod require.Equal(t, p1.ID, p2.ID) require.Equal(t, p1.DisplayName, p2.DisplayName) require.Equal(t, p1.PostDurationDays, p2.PostDurationDays) - require.Equal(t, len(p1.ChannelIDs), len(p2.ChannelIDs)) - if p1.ChannelIDs == nil || p2.ChannelIDs == nil { - require.Equal(t, p1.ChannelIDs, p2.ChannelIDs) - } else { - sort.Strings(p1.ChannelIDs) - sort.Strings(p2.ChannelIDs) - } - for i := range p1.ChannelIDs { - require.Equal(t, p1.ChannelIDs[i], p2.ChannelIDs[i]) - } - if p1.TeamIDs == nil || p2.TeamIDs == nil { - require.Equal(t, p1.TeamIDs, p2.TeamIDs) - } else { - sort.Strings(p1.TeamIDs) - sort.Strings(p2.TeamIDs) - } - require.Equal(t, len(p1.TeamIDs), len(p2.TeamIDs)) - for i := range p1.TeamIDs { - require.Equal(t, p1.TeamIDs[i], p2.TeamIDs[i]) - } + require.ElementsMatch(t, p1.ChannelIDs, p2.ChannelIDs) + require.ElementsMatch(t, p1.TeamIDs, p2.TeamIDs) } func CheckRetentionPolicyWithTeamAndChannelCountsAreEqual(t *testing.T, p1, p2 *model.RetentionPolicyWithTeamAndChannelCounts) { @@ -431,13 +412,11 @@ func testRetentionPolicyStoreGetChannels(t *testing.T, rctx request.CTX, ss stor channels, err := ss.RetentionPolicy().GetChannels(policy.ID, 0, len(channelIDs)) require.NoError(t, err) require.Len(t, channels, len(channelIDs)) - sort.Strings(channelIDs) - sort.Slice(channels, func(i, j int) bool { - return channels[i].Id < channels[j].Id - }) - for i := range channelIDs { - require.Equal(t, channelIDs[i], channels[i].Id) + actualIDs := make([]string, len(channels)) + for i, ch := range channels { + actualIDs[i] = ch.Id } + require.ElementsMatch(t, channelIDs, actualIDs) }) } @@ -527,13 +506,11 @@ func testRetentionPolicyStoreGetTeams(t *testing.T, rctx request.CTX, ss store.S teams, err := ss.RetentionPolicy().GetTeams(policy.ID, 0, len(teamIDs)) require.NoError(t, err) require.Len(t, teams, len(teamIDs)) - sort.Strings(teamIDs) - sort.Slice(teams, func(i, j int) bool { - return teams[i].Id < teams[j].Id - }) - for i := range teamIDs { - require.Equal(t, teamIDs[i], teams[i].Id) + actualIDs := make([]string, len(teams)) + for i, tm := range teams { + actualIDs[i] = tm.Id } + require.ElementsMatch(t, teamIDs, actualIDs) }) } diff --git a/server/channels/store/storetest/store.go b/server/channels/store/storetest/store.go index 0359aa256dd..00e7a30fb46 100644 --- a/server/channels/store/storetest/store.go +++ b/server/channels/store/storetest/store.go @@ -71,6 +71,7 @@ type Store struct { AttributesStore mocks.AttributesStore AutoTranslationStore mocks.AutoTranslationStore ContentFlaggingStore mocks.ContentFlaggingStore + RecapStore mocks.RecapStore ReadReceiptStore mocks.ReadReceiptStore TemporaryPostStore mocks.TemporaryPostStore } @@ -169,6 +170,9 @@ func (s *Store) AutoTranslation() store.AutoTranslationStore { func (s *Store) ContentFlagging() store.ContentFlaggingStore { return &s.ContentFlaggingStore } +func (s *Store) Recap() store.RecapStore { + return &s.RecapStore +} func (s *Store) ReadReceipt() store.ReadReceiptStore { return &s.ReadReceiptStore } @@ -227,6 +231,7 @@ func (s *Store) AssertExpectations(t mock.TestingT) bool { &s.AttributesStore, &s.AutoTranslationStore, &s.ContentFlaggingStore, + &s.RecapStore, &s.ReadReceiptStore, &s.TemporaryPostStore, ) diff --git a/server/channels/store/storetest/thread_store.go b/server/channels/store/storetest/thread_store.go index ef7b32e9a0f..e77bb26ccd6 100644 --- a/server/channels/store/storetest/thread_store.go +++ b/server/channels/store/storetest/thread_store.go @@ -5,7 +5,6 @@ package storetest import ( "context" - "sort" "testing" "time" @@ -797,12 +796,6 @@ func testGetTeamsUnreadForUser(t *testing.T, rctx request.CTX, ss store.Store) { assert.Equal(t, int64(1), teamsUnread[team2.Id].ThreadUrgentMentionCount) } -type byPostId []*model.Post - -func (a byPostId) Len() int { return len(a) } -func (a byPostId) Swap(i, j int) { a[i], a[j] = a[j], a[i] } -func (a byPostId) Less(i, j int) bool { return a[i].Id < a[j].Id } - func testVarious(t *testing.T, rctx request.CTX, ss store.Store) { createThreadMembership := func(userID, postID string, isMention bool) { t.Helper() @@ -1163,7 +1156,6 @@ func testVarious(t *testing.T, rctx request.CTX, ss store.Store) { require.True(t, ok, "failed to find actual %s in post names", thread.PostId) actualPostNames = append(actualPostNames, postName) } - sort.Strings(actualPostNames) expectedPostNames := make([]string, 0, len(expectedPosts)) for _, post := range expectedPosts { @@ -1171,20 +1163,9 @@ func testVarious(t *testing.T, rctx request.CTX, ss store.Store) { require.True(t, ok, "failed to find expected %s in post names", post.Id) expectedPostNames = append(expectedPostNames, postName) } - sort.Strings(expectedPostNames) - assert.Equal(t, expectedPostNames, actualPostNames) - - // Check posts themselves - sort.Sort(byPostId(expectedPosts)) - sort.Sort(byPostId(actualPosts)) - if assert.Len(t, actualPosts, len(expectedPosts)) { - for i := range actualPosts { - assert.Equal(t, expectedPosts[i], actualPosts[i], "mismatch comparing expected post %s with actual post %s", postNames[expectedPosts[i].Id], postNames[actualPosts[i].Id]) - } - } else { - assert.Equal(t, expectedPosts, actualPosts) - } + assert.ElementsMatch(t, expectedPostNames, actualPostNames) + assert.ElementsMatch(t, expectedPosts, actualPosts) // Check common fields between threads and posts. for _, thread := range threads { diff --git a/server/channels/testlib/helper.go b/server/channels/testlib/helper.go index ea8b7932daa..38d0d2242b4 100644 --- a/server/channels/testlib/helper.go +++ b/server/channels/testlib/helper.go @@ -254,7 +254,6 @@ func (h *MainHelper) setupResources() { // // Re-generate the files with: // pg_dump -a -h localhost -U mmuser -d <> --no-comments --inserts -t roles -t systems -// mysqldump -u root -p <> --no-create-info --extended-insert=FALSE Systems Roles // And keep only the permission related rows in the systems table output. func preloadMigrations(driverName string, sqlStore *sqlstore.SqlStore) { var buf []byte diff --git a/server/channels/testlib/testdata/boards_mysql_migration_warmup.sql b/server/channels/testlib/testdata/boards_mysql_migration_warmup.sql deleted file mode 100644 index d279598e928..00000000000 --- a/server/channels/testlib/testdata/boards_mysql_migration_warmup.sql +++ /dev/null @@ -1,41 +0,0 @@ --- MySQL dump 10.13 Distrib 5.7.12, for Linux (x86_64) --- --- Host: localhost Database: mattermost_test --- ------------------------------------------------------ --- Server version 5.7.12 - -/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; -/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; -/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; -/*!40101 SET NAMES utf8 */; -/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; -/*!40103 SET TIME_ZONE='+00:00' */; -/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; -/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; -/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; -/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; - --- --- Dumping data for table `focalboard_system_settings` --- - -LOCK TABLES `focalboard_system_settings` WRITE; -/*!40000 ALTER TABLE `focalboard_system_settings` DISABLE KEYS */; -INSERT INTO `focalboard_system_settings` VALUES ('CategoryUuidIdMigrationComplete','true'); -INSERT INTO `focalboard_system_settings` VALUES ('DeDuplicateCategoryBoardTableComplete','true'); -INSERT INTO `focalboard_system_settings` VALUES ('DeletedMembershipBoardsMigrationComplete','true'); -INSERT INTO `focalboard_system_settings` VALUES ('TeamLessBoardsMigrationComplete','true'); -INSERT INTO `focalboard_system_settings` VALUES ('UniqueIDsMigrationComplete','true'); -/*!40000 ALTER TABLE `focalboard_system_settings` ENABLE KEYS */; -UNLOCK TABLES; -/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; - -/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; -/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; -/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; -/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; -/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; -/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; -/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; - --- Dump completed on 2023-03-31 11:37:35 diff --git a/server/channels/testlib/testdata/mysql_migration_warmup.sql b/server/channels/testlib/testdata/mysql_migration_warmup.sql deleted file mode 100644 index b76f793c431..00000000000 --- a/server/channels/testlib/testdata/mysql_migration_warmup.sql +++ /dev/null @@ -1,106 +0,0 @@ --- MySQL dump 10.13 Distrib 8.0.23, for Linux (x86_64) --- --- Host: localhost Database: dbwidnrtyyj7nhxnj5nkq5s7te7c --- ------------------------------------------------------ --- Server version 8.0.23 - -/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; -/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; -/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; -/*!50503 SET NAMES utf8mb4 */; -/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; -/*!40103 SET TIME_ZONE='+00:00' */; -/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; -/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; -/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; -/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; - --- --- Dumping data for table `Systems` --- - -LOCK TABLES `Systems` WRITE; -/*!40000 ALTER TABLE `Systems` DISABLE KEYS */; -INSERT INTO `Systems` VALUES ('about_subsection_permissions','true'); -INSERT INTO `Systems` VALUES ('add_billing_permissions','true'); -INSERT INTO `Systems` VALUES ('add_bot_permissions','true'); -INSERT INTO `Systems` VALUES ('add_convert_channel_permissions','true'); -INSERT INTO `Systems` VALUES ('add_manage_guests_permissions','true'); -INSERT INTO `Systems` VALUES ('add_system_console_permissions','true'); -INSERT INTO `Systems` VALUES ('add_system_roles_permissions','true'); -INSERT INTO `Systems` VALUES ('add_use_group_mentions_permission','true'); -INSERT INTO `Systems` VALUES ('AdvancedPermissionsMigrationComplete','true'); -INSERT INTO `Systems` VALUES ('apply_channel_manage_delete_to_channel_user','true'); -INSERT INTO `Systems` VALUES ('authentication_subsection_permissions','true'); -INSERT INTO `Systems` VALUES ('channel_moderations_permissions','true'); -INSERT INTO `Systems` VALUES ('compliance_subsection_permissions','true'); -INSERT INTO `Systems` VALUES ('ContentExtractionConfigDefaultTrueMigrationComplete','true'); -INSERT INTO `Systems` VALUES ('custom_groups_permissions','true'); -INSERT INTO `Systems` VALUES ('CustomGroupAdminRoleCreationMigrationComplete','true'); -INSERT INTO `Systems` VALUES ('download_compliance_export_results','true'); -INSERT INTO `Systems` VALUES ('emoji_permissions_split','true'); -INSERT INTO `Systems` VALUES ('EmojisPermissionsMigrationComplete','true'); -INSERT INTO `Systems` VALUES ('environment_subsection_permissions','true'); -INSERT INTO `Systems` VALUES ('experimental_subsection_permissions','true'); -INSERT INTO `Systems` VALUES ('GuestRolesCreationMigrationComplete','true'); -INSERT INTO `Systems` VALUES ('integrations_subsection_permissions','true'); -INSERT INTO `Systems` VALUES ('list_join_public_private_teams','true'); -INSERT INTO `Systems` VALUES ('manage_secure_connections_permissions','true'); -INSERT INTO `Systems` VALUES ('manage_shared_channel_permissions','true'); -INSERT INTO `Systems` VALUES ('PlaybookRolesCreationMigrationComplete','true'); -INSERT INTO `Systems` VALUES ('playbooks_manage_roles','true'); -INSERT INTO `Systems` VALUES ('playbooks_permissions','true'); -INSERT INTO `Systems` VALUES ('remove_channel_manage_delete_from_team_user','true'); -INSERT INTO `Systems` VALUES ('remove_permanent_delete_user','true'); -INSERT INTO `Systems` VALUES ('reporting_subsection_permissions','true'); -INSERT INTO `Systems` VALUES ('site_subsection_permissions','true'); -INSERT INTO `Systems` VALUES ('SystemConsoleRolesCreationMigrationComplete','true'); -INSERT INTO `Systems` VALUES ('test_email_ancillary_permission','true'); -INSERT INTO `Systems` VALUES ('Version','5.31.0'); -INSERT INTO `Systems` VALUES ('view_members_new_permission','true'); -INSERT INTO `Systems` VALUES ('webhook_permissions_split','true'); -/*!40000 ALTER TABLE `Systems` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Dumping data for table `Roles` --- - -LOCK TABLES `Roles` WRITE; -/*!40000 ALTER TABLE `Roles` DISABLE KEYS */; -INSERT INTO `Roles` VALUES ('3ndsqn4sbbyjxpzccrzmzejstw','team_guest','authentication.roles.team_guest.name','authentication.roles.team_guest.description',1605167829008,1662271986894,0,' view_team',1,1); -INSERT INTO `Roles` VALUES ('44bq9f9s93b7f811ex5r1b4s1w','system_custom_group_admin','authentication.roles.system_custom_group_admin.name','authentication.roles.system_custom_group_admin.description',1662271985879,1662271986897,0,' manage_custom_group_members create_custom_group edit_custom_group delete_custom_group',0,1); -INSERT INTO `Roles` VALUES ('6jaz4y4nmjnxunkmogjf95fiha','system_user_manager','authentication.roles.system_user_manager.name','authentication.roles.system_user_manager.description',0,1662271986902,0,' delete_public_channel sysconsole_write_user_management_channels list_public_teams sysconsole_read_authentication_ldap sysconsole_write_user_management_groups manage_private_channel_members sysconsole_read_user_management_permissions sysconsole_read_authentication_password read_channel join_private_teams manage_team sysconsole_read_user_management_teams sysconsole_read_authentication_email sysconsole_write_user_management_teams read_public_channel_groups list_private_teams convert_private_channel_to_public manage_team_roles convert_public_channel_to_private sysconsole_read_authentication_openid view_team add_user_to_team read_ldap_sync_job read_public_channel test_ldap manage_private_channel_properties delete_private_channel manage_channel_roles sysconsole_read_authentication_guest_access sysconsole_read_user_management_channels sysconsole_read_user_management_groups manage_public_channel_members sysconsole_read_authentication_saml remove_user_from_team join_public_teams manage_public_channel_properties sysconsole_read_authentication_mfa sysconsole_read_authentication_signup read_private_channel_groups',0,1); -INSERT INTO `Roles` VALUES ('6pahsh5hg7rpjfhz4f5c1wsbfw','team_admin','authentication.roles.team_admin.name','authentication.roles.team_admin.description',0,1662271986906,0,' remove_user_from_team manage_slash_commands manage_team_roles delete_others_posts manage_others_slash_commands import_team manage_others_outgoing_webhooks convert_private_channel_to_public delete_post playbook_private_manage_roles convert_public_channel_to_private manage_channel_roles playbook_public_manage_roles manage_outgoing_webhooks manage_others_incoming_webhooks manage_incoming_webhooks manage_team',1,1); -INSERT INTO `Roles` VALUES ('c7oo8yeiojfu8xjyuyxn3fhxpc','team_post_all','authentication.roles.team_post_all.name','authentication.roles.team_post_all.description',0,1662271986910,0,' create_post use_channel_mentions',0,1); -INSERT INTO `Roles` VALUES ('cmqctq1egt877y9ua9pdsknoiw','team_post_all_public','authentication.roles.team_post_all_public.name','authentication.roles.team_post_all_public.description',0,1662271986914,0,' create_post_public use_channel_mentions',0,1); -INSERT INTO `Roles` VALUES ('dmmkxxmi3b8pdgcq9pjtf6mfao','playbook_member','authentication.roles.playbook_member.name','authentication.roles.playbook_member.description',1662271985811,1662271986918,0,' playbook_private_manage_members playbook_private_manage_properties run_create playbook_public_view playbook_public_manage_members playbook_public_manage_properties playbook_private_view',1,1); -INSERT INTO `Roles` VALUES ('dwjkqj9bj7r4xr8zb18z3tg53y','playbook_admin','authentication.roles.playbook_admin.name','authentication.roles.playbook_admin.description',1662271985841,1662271986921,0,' playbook_private_manage_roles playbook_private_manage_properties playbook_public_make_private playbook_public_manage_members playbook_public_manage_roles playbook_public_manage_properties playbook_private_manage_members',1,1); -INSERT INTO `Roles` VALUES ('hh56iy3patffuc3h76soondcga','channel_admin','authentication.roles.channel_admin.name','authentication.roles.channel_admin.description',0,1662271986924,0,' manage_channel_roles use_group_mentions',1,1); -INSERT INTO `Roles` VALUES ('hkcrew7wttb5fbuw3ime6g7nzc','system_read_only_admin','authentication.roles.system_read_only_admin.name','authentication.roles.system_read_only_admin.description',0,1662271986928,0,' sysconsole_read_environment_database sysconsole_read_experimental_features sysconsole_read_compliance_compliance_export sysconsole_read_environment_performance_monitoring sysconsole_read_environment_file_storage sysconsole_read_user_management_channels read_public_channel_groups read_elasticsearch_post_aggregation_job sysconsole_read_integrations_integration_management sysconsole_read_environment_push_notification_server read_compliance_export_job sysconsole_read_user_management_teams sysconsole_read_environment_logging sysconsole_read_about_edition_and_license sysconsole_read_site_customization sysconsole_read_reporting_site_statistics sysconsole_read_site_emoji sysconsole_read_authentication_guest_access test_ldap read_audits sysconsole_read_site_posts download_compliance_export_result sysconsole_read_compliance_compliance_monitoring sysconsole_read_site_announcement_banner sysconsole_read_integrations_gif sysconsole_read_authentication_email sysconsole_read_site_file_sharing_and_downloads sysconsole_read_compliance_data_retention_policy read_channel sysconsole_read_experimental_feature_flags sysconsole_read_environment_image_proxy view_team sysconsole_read_authentication_openid sysconsole_read_environment_web_server sysconsole_read_integrations_cors read_ldap_sync_job sysconsole_read_authentication_saml get_analytics read_private_channel_groups sysconsole_read_reporting_team_statistics sysconsole_read_compliance_custom_terms_of_service sysconsole_read_authentication_ldap sysconsole_read_environment_smtp read_other_users_teams sysconsole_read_user_management_permissions sysconsole_read_environment_session_lengths read_public_channel read_data_retention_job sysconsole_read_user_management_groups sysconsole_read_environment_high_availability sysconsole_read_site_public_links sysconsole_read_authentication_password sysconsole_read_environment_rate_limiting list_public_teams sysconsole_read_site_users_and_teams sysconsole_read_authentication_signup get_logs read_license_information sysconsole_read_site_notices list_private_teams read_elasticsearch_post_indexing_job sysconsole_read_site_notifications sysconsole_read_authentication_mfa sysconsole_read_integrations_bot_accounts sysconsole_read_reporting_server_logs sysconsole_read_site_localization sysconsole_read_environment_elasticsearch sysconsole_read_user_management_users sysconsole_read_plugins sysconsole_read_environment_developer',0,1); -INSERT INTO `Roles` VALUES ('iiwt9pt6wiyb9e1enixtxs5yme','run_admin','authentication.roles.run_admin.name','authentication.roles.run_admin.description',1662271985864,1662271986932,0,' run_manage_properties run_manage_members',1,1); -INSERT INTO `Roles` VALUES ('jg1f1xfh3bb73pua938orwg9ie','system_guest','authentication.roles.global_guest.name','authentication.roles.global_guest.description',1605167829015,1662271986937,0,' create_direct_channel create_group_channel',1,1); -INSERT INTO `Roles` VALUES ('k891n5tpd3n9peue79azejjocy','system_post_all_public','authentication.roles.system_post_all_public.name','authentication.roles.system_post_all_public.description',0,1662271986941,0,' use_channel_mentions create_post_public',0,1); -INSERT INTO `Roles` VALUES ('kb6r9i58x7dxdb3srfohd66sse','system_admin','authentication.roles.global_admin.name','authentication.roles.global_admin.description',0,1662271986948,0,' list_public_teams edit_brand manage_private_channel_properties sysconsole_read_user_management_teams playbook_public_create manage_others_bots invalidate_caches manage_shared_channels sysconsole_write_environment_logging manage_others_outgoing_webhooks sysconsole_read_reporting_team_statistics sysconsole_read_plugins list_team_channels use_group_mentions sysconsole_read_site_users_and_teams sysconsole_write_site_localization get_analytics manage_team_roles sysconsole_read_site_localization edit_post sysconsole_write_user_management_channels test_elasticsearch list_private_teams add_ldap_public_cert join_public_teams manage_slash_commands manage_others_incoming_webhooks manage_public_channel_members sysconsole_read_environment_elasticsearch sysconsole_write_site_customization delete_others_emojis run_manage_members create_emojis sysconsole_write_authentication_email sysconsole_write_compliance_compliance_export add_saml_private_cert create_bot sysconsole_write_environment_rate_limiting add_saml_public_cert edit_other_users sysconsole_write_integrations_integration_management read_user_access_token create_elasticsearch_post_indexing_job sysconsole_write_user_management_users assign_system_admin_role sysconsole_write_user_management_groups sysconsole_read_authentication_guest_access sysconsole_write_about_edition_and_license sysconsole_read_authentication_ldap sysconsole_read_experimental_feature_flags sysconsole_read_integrations_cors sysconsole_read_user_management_groups join_public_channels sysconsole_read_experimental_features test_ldap sysconsole_write_environment_elasticsearch sysconsole_write_reporting_server_logs sysconsole_read_environment_image_proxy sysconsole_read_site_announcement_banner sysconsole_read_reporting_site_statistics sysconsole_write_authentication_mfa sysconsole_read_authentication_openid playbook_public_manage_members delete_emojis sysconsole_write_environment_file_storage sysconsole_write_reporting_site_statistics playbook_private_manage_members import_team sysconsole_write_environment_web_server sysconsole_write_authentication_password read_public_channel_groups create_compliance_export_job sysconsole_read_authentication_password list_users_without_team sysconsole_read_authentication_mfa add_ldap_private_cert create_data_retention_job read_license_information sysconsole_write_authentication_signup sysconsole_read_environment_push_notification_server edit_others_posts download_compliance_export_result create_ldap_sync_job sysconsole_write_authentication_ldap sysconsole_write_plugins read_data_retention_job sysconsole_write_compliance_data_retention_policy sysconsole_read_site_public_links manage_bots manage_system sysconsole_write_compliance_custom_terms_of_service playbook_public_manage_roles playbook_public_manage_properties playbook_private_create sysconsole_read_authentication_email promote_guest get_saml_cert_status add_user_to_team sysconsole_write_site_users_and_teams create_custom_group manage_private_channel_members read_jobs sysconsole_write_experimental_features read_other_users_teams sysconsole_write_reporting_team_statistics sysconsole_read_environment_file_storage sysconsole_read_site_file_sharing_and_downloads playbook_private_make_public playbook_public_view create_user_access_token create_public_channel read_channel sysconsole_read_user_management_channels sysconsole_read_user_management_permissions read_public_channel sysconsole_read_compliance_custom_terms_of_service sysconsole_write_site_emoji sysconsole_read_integrations_gif sysconsole_read_site_customization sysconsole_write_integrations_cors invite_user create_direct_channel sysconsole_write_user_management_teams run_create manage_custom_group_members read_ldap_sync_job sysconsole_read_site_notifications playbook_private_manage_properties sysconsole_read_integrations_bot_accounts convert_public_channel_to_private invalidate_email_invite reload_config get_saml_metadata_from_idp manage_secure_connections delete_private_channel sysconsole_read_about_edition_and_license convert_private_channel_to_public sysconsole_read_environment_developer recycle_database_connections remove_saml_private_cert manage_oauth sysconsole_write_environment_database sysconsole_write_site_notifications sysconsole_write_authentication_guest_access sysconsole_write_compliance_compliance_monitoring sysconsole_write_environment_image_proxy create_post_public manage_jobs remove_user_from_team delete_others_posts create_post_ephemeral playbook_private_view create_elasticsearch_post_aggregation_job remove_reaction add_reaction sysconsole_write_environment_high_availability sysconsole_write_authentication_openid sysconsole_write_user_management_permissions add_saml_idp_cert sysconsole_read_site_posts view_members sysconsole_write_environment_smtp sysconsole_read_authentication_saml create_post use_channel_mentions create_team playbook_private_manage_roles get_public_link sysconsole_write_billing manage_system_wide_oauth sysconsole_read_environment_database sysconsole_write_environment_session_lengths run_manage_properties sysconsole_write_authentication_saml sysconsole_read_environment_web_server sysconsole_read_environment_rate_limiting manage_public_channel_properties create_group_channel sysconsole_read_compliance_data_retention_policy sysconsole_read_environment_high_availability manage_others_slash_commands sysconsole_read_compliance_compliance_export delete_custom_group sysconsole_read_user_management_system_roles purge_elasticsearch_indexes view_team sysconsole_read_environment_performance_monitoring manage_channel_roles playbook_public_make_private remove_saml_public_cert demote_to_guest sysconsole_write_environment_performance_monitoring read_audits sysconsole_write_site_announcement_banner upload_file revoke_user_access_token read_others_bots test_email read_elasticsearch_post_aggregation_job sysconsole_read_compliance_compliance_monitoring join_private_teams delete_post sysconsole_write_site_public_links manage_team edit_custom_group sysconsole_write_experimental_feature_flags sysconsole_write_user_management_system_roles remove_others_reactions manage_license_information sysconsole_read_authentication_signup read_compliance_export_job sysconsole_write_environment_developer remove_saml_idp_cert manage_incoming_webhooks sysconsole_read_site_emoji assign_bot sysconsole_write_integrations_gif sysconsole_read_user_management_users delete_public_channel manage_outgoing_webhooks sysconsole_write_site_posts remove_ldap_private_cert sysconsole_write_site_file_sharing_and_downloads sysconsole_read_integrations_integration_management sysconsole_read_environment_logging test_site_url sysconsole_read_environment_session_lengths read_elasticsearch_post_indexing_job sysconsole_read_billing sysconsole_read_site_notices sysconsole_read_reporting_server_logs sysconsole_write_integrations_bot_accounts sysconsole_write_site_notices create_private_channel read_private_channel_groups run_view read_bots manage_roles test_s3 sysconsole_write_environment_push_notification_server get_logs invite_guest remove_ldap_public_cert sysconsole_read_environment_smtp',1,1); -INSERT INTO `Roles` VALUES ('km7kijhdtjbajquwu36uqneyoc','system_post_all','authentication.roles.system_post_all.name','authentication.roles.system_post_all.description',0,1662271986953,0,' create_post use_channel_mentions',0,1); -INSERT INTO `Roles` VALUES ('no7s4436sjbzzqjpupg85mszty','custom_group_user','authentication.roles.custom_group_user.name','authentication.roles.custom_group_user.description',1662271985801,1662271986956,0,'',0,0); -INSERT INTO `Roles` VALUES ('qo7e17c1m3rezyjqx5iq9dpmxe','system_manager','authentication.roles.system_manager.name','authentication.roles.system_manager.description',0,1662271986960,0,' sysconsole_write_environment_image_proxy sysconsole_read_environment_developer read_ldap_sync_job sysconsole_read_reporting_team_statistics recycle_database_connections get_logs read_private_channel_groups test_elasticsearch sysconsole_read_environment_logging purge_elasticsearch_indexes sysconsole_write_site_posts sysconsole_read_environment_database sysconsole_read_environment_performance_monitoring manage_team sysconsole_read_authentication_password sysconsole_write_site_users_and_teams sysconsole_read_user_management_channels sysconsole_write_environment_rate_limiting sysconsole_write_site_notifications read_license_information edit_brand sysconsole_read_plugins sysconsole_read_environment_high_availability sysconsole_read_environment_file_storage sysconsole_read_environment_elasticsearch sysconsole_write_environment_web_server sysconsole_write_environment_smtp sysconsole_write_environment_performance_monitoring sysconsole_write_environment_session_lengths sysconsole_write_user_management_groups convert_private_channel_to_public manage_private_channel_properties sysconsole_read_site_posts list_private_teams sysconsole_read_authentication_ldap sysconsole_read_authentication_guest_access sysconsole_read_site_emoji sysconsole_write_integrations_integration_management convert_public_channel_to_private manage_private_channel_members read_elasticsearch_post_aggregation_job manage_team_roles sysconsole_write_site_file_sharing_and_downloads read_channel read_public_channel sysconsole_read_authentication_openid add_user_to_team sysconsole_write_environment_developer sysconsole_write_site_localization sysconsole_read_about_edition_and_license test_s3 reload_config sysconsole_write_environment_elasticsearch test_site_url sysconsole_write_site_announcement_banner get_analytics sysconsole_read_environment_push_notification_server sysconsole_read_authentication_signup test_email sysconsole_write_integrations_bot_accounts sysconsole_write_integrations_cors view_team sysconsole_write_integrations_gif sysconsole_read_site_notices sysconsole_read_environment_image_proxy sysconsole_read_integrations_cors sysconsole_write_environment_push_notification_server join_public_teams test_ldap create_elasticsearch_post_aggregation_job sysconsole_read_environment_session_lengths sysconsole_write_environment_file_storage manage_public_channel_members sysconsole_write_site_customization sysconsole_read_site_announcement_banner sysconsole_read_environment_smtp sysconsole_write_user_management_teams delete_public_channel sysconsole_write_environment_logging read_public_channel_groups sysconsole_read_site_users_and_teams sysconsole_read_reporting_site_statistics sysconsole_read_site_localization sysconsole_read_site_customization sysconsole_read_environment_rate_limiting sysconsole_read_environment_web_server sysconsole_write_user_management_permissions sysconsole_read_site_file_sharing_and_downloads sysconsole_write_site_public_links sysconsole_read_site_public_links sysconsole_read_authentication_email read_elasticsearch_post_indexing_job sysconsole_read_authentication_saml remove_user_from_team delete_private_channel sysconsole_write_user_management_channels sysconsole_read_reporting_server_logs sysconsole_read_integrations_bot_accounts sysconsole_read_user_management_teams list_public_teams create_elasticsearch_post_indexing_job sysconsole_write_site_emoji invalidate_caches sysconsole_read_integrations_integration_management sysconsole_write_environment_high_availability sysconsole_read_user_management_permissions join_private_teams manage_channel_roles sysconsole_write_site_notices manage_public_channel_properties sysconsole_write_environment_database sysconsole_read_site_notifications sysconsole_read_user_management_groups sysconsole_read_integrations_gif sysconsole_read_authentication_mfa',0,1); -INSERT INTO `Roles` VALUES ('rkr97ikkh7fixy86qsoo5rqm4c','system_user_access_token','authentication.roles.system_user_access_token.name','authentication.roles.system_user_access_token.description',0,1662271986965,0,' create_user_access_token read_user_access_token revoke_user_access_token',0,1); -INSERT INTO `Roles` VALUES ('rxzdk5irm7rcffcfej9e33kqeo','team_user','authentication.roles.team_user.name','authentication.roles.team_user.description',0,1662271986968,0,' invite_user view_team read_public_channel playbook_public_create add_user_to_team playbook_private_create create_private_channel list_team_channels create_public_channel join_public_channels',1,1); -INSERT INTO `Roles` VALUES ('x768jnyzw3rkfx7xb66ehcac6o','channel_user','authentication.roles.channel_user.name','authentication.roles.channel_user.description',0,1662271986972,0,' manage_public_channel_properties create_post manage_private_channel_properties delete_public_channel manage_private_channel_members get_public_link delete_post delete_private_channel upload_file edit_post remove_reaction use_channel_mentions add_reaction read_channel manage_public_channel_members',1,1); -INSERT INTO `Roles` VALUES ('ynn8aynsn7n1trtbuq6p4cyzhe','channel_guest','authentication.roles.channel_guest.name','authentication.roles.channel_guest.description',1605167829001,1662271986975,0,' read_channel add_reaction remove_reaction upload_file edit_post create_post use_channel_mentions',1,1); -INSERT INTO `Roles` VALUES ('yqyby79r9jggxg7a9dnenuawmo','run_member','authentication.roles.run_member.name','authentication.roles.run_member.description',1662271985813,1662271986979,0,' run_view',1,1); -INSERT INTO `Roles` VALUES ('zzehkfnp67bg5g1owh6eptdcxc','system_user','authentication.roles.global_user.name','authentication.roles.global_user.description',0,1662271986983,0,' create_emojis join_public_teams list_public_teams edit_custom_group delete_emojis create_team create_group_channel manage_custom_group_members view_members delete_custom_group create_custom_group create_direct_channel',1,1); -/*!40000 ALTER TABLE `Roles` ENABLE KEYS */; -UNLOCK TABLES; -/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; - -/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; -/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; -/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; -/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; -/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; -/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; -/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; - --- Dump completed on 2022-09-04 6:13:45 diff --git a/server/channels/utils/utils.go b/server/channels/utils/utils.go index b66b30a0526..ed442cd7929 100644 --- a/server/channels/utils/utils.go +++ b/server/channels/utils/utils.go @@ -188,20 +188,28 @@ func AppendQueryParamsToURL(baseURL string, params map[string]string) string { return u.String() } -// Validates RedirectURL passed during OAuth or SAML -func IsValidWebAuthRedirectURL(config *model.Config, redirectURL string) bool { +// ValidateWebAuthRedirectUrl validates a RedirectURL passed during OAuth or SAML. +func ValidateWebAuthRedirectUrl(config *model.Config, redirectURL string) error { u, err := url.Parse(redirectURL) - if err != nil || config.ServiceSettings.SiteURL == nil { - return false + if err != nil { + return errors.Wrap(err, "failed to parse redirect URL") + } + + if config.ServiceSettings.SiteURL == nil { + return errors.New("SiteURL is not configured") } siteURL, err := url.Parse(*config.ServiceSettings.SiteURL) if err != nil { - return false + return errors.Wrap(err, "failed to parse SiteURL from config") } - if u.Scheme == siteURL.Scheme && u.Host == siteURL.Host { - return true + + if u.Scheme != siteURL.Scheme { + return errors.Errorf("redirect URL scheme %q does not match site URL scheme %q", u.Scheme, siteURL.Scheme) } - return false + if u.Host != siteURL.Host { + return errors.Errorf("redirect URL host %q does not match site URL host %q", u.Host, siteURL.Host) + } + return nil } // Validates Mobile Custom URL Scheme passed during OAuth or SAML diff --git a/server/channels/utils/utils_test.go b/server/channels/utils/utils_test.go index 5314209e1e4..ab4dee99ecb 100644 --- a/server/channels/utils/utils_test.go +++ b/server/channels/utils/utils_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/model" ) @@ -444,8 +445,8 @@ func TestIsValidWebAuthRedirectURL(t *testing.T) { } redirectURL := "https://example.com/oauth/callback" - result := IsValidWebAuthRedirectURL(config, redirectURL) - assert.True(t, result) + err := ValidateWebAuthRedirectUrl(config, redirectURL) + require.NoError(t, err) }) t.Run("Valid redirect URL with matching scheme and host with port", func(t *testing.T) { @@ -456,8 +457,8 @@ func TestIsValidWebAuthRedirectURL(t *testing.T) { } redirectURL := "https://example.com:8080/oauth/callback" - result := IsValidWebAuthRedirectURL(config, redirectURL) - assert.True(t, result) + err := ValidateWebAuthRedirectUrl(config, redirectURL) + require.NoError(t, err) }) t.Run("Invalid redirect URL with different scheme", func(t *testing.T) { @@ -468,8 +469,9 @@ func TestIsValidWebAuthRedirectURL(t *testing.T) { } redirectURL := "http://example.com/oauth/callback" - result := IsValidWebAuthRedirectURL(config, redirectURL) - assert.False(t, result) + err := ValidateWebAuthRedirectUrl(config, redirectURL) + require.Error(t, err) + assert.Contains(t, err.Error(), "scheme") }) t.Run("Invalid redirect URL with different host", func(t *testing.T) { @@ -480,8 +482,9 @@ func TestIsValidWebAuthRedirectURL(t *testing.T) { } redirectURL := "https://malicious.com/oauth/callback" - result := IsValidWebAuthRedirectURL(config, redirectURL) - assert.False(t, result) + err := ValidateWebAuthRedirectUrl(config, redirectURL) + require.Error(t, err) + assert.Contains(t, err.Error(), "host") }) t.Run("Invalid redirect URL with different port", func(t *testing.T) { @@ -492,8 +495,9 @@ func TestIsValidWebAuthRedirectURL(t *testing.T) { } redirectURL := "https://example.com:9090/oauth/callback" - result := IsValidWebAuthRedirectURL(config, redirectURL) - assert.False(t, result) + err := ValidateWebAuthRedirectUrl(config, redirectURL) + require.Error(t, err) + assert.Contains(t, err.Error(), "host") }) t.Run("Invalid redirect URL - malformed URL", func(t *testing.T) { @@ -502,10 +506,11 @@ func TestIsValidWebAuthRedirectURL(t *testing.T) { SiteURL: model.NewPointer("https://example.com"), }, } - redirectURL := "not-a-valid-url" + redirectURL := "://not-a-valid-url" - result := IsValidWebAuthRedirectURL(config, redirectURL) - assert.False(t, result) + err := ValidateWebAuthRedirectUrl(config, redirectURL) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse redirect URL") }) t.Run("Invalid config - nil SiteURL", func(t *testing.T) { @@ -516,20 +521,22 @@ func TestIsValidWebAuthRedirectURL(t *testing.T) { } redirectURL := "https://example.com/oauth/callback" - result := IsValidWebAuthRedirectURL(config, redirectURL) - assert.False(t, result) + err := ValidateWebAuthRedirectUrl(config, redirectURL) + require.Error(t, err) + assert.Contains(t, err.Error(), "SiteURL is not configured") }) t.Run("Invalid config - malformed SiteURL", func(t *testing.T) { config := &model.Config{ ServiceSettings: model.ServiceSettings{ - SiteURL: model.NewPointer("not-a-valid-url"), + SiteURL: model.NewPointer("://not-a-valid-url"), }, } redirectURL := "https://example.com/oauth/callback" - result := IsValidWebAuthRedirectURL(config, redirectURL) - assert.False(t, result) + err := ValidateWebAuthRedirectUrl(config, redirectURL) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse SiteURL") }) t.Run("Valid redirect URL with subdomain", func(t *testing.T) { @@ -540,8 +547,8 @@ func TestIsValidWebAuthRedirectURL(t *testing.T) { } redirectURL := "https://app.example.com/oauth/callback" - result := IsValidWebAuthRedirectURL(config, redirectURL) - assert.True(t, result) + err := ValidateWebAuthRedirectUrl(config, redirectURL) + require.NoError(t, err) }) t.Run("Invalid redirect URL with different subdomain", func(t *testing.T) { @@ -552,8 +559,9 @@ func TestIsValidWebAuthRedirectURL(t *testing.T) { } redirectURL := "https://api.example.com/oauth/callback" - result := IsValidWebAuthRedirectURL(config, redirectURL) - assert.False(t, result) + err := ValidateWebAuthRedirectUrl(config, redirectURL) + require.Error(t, err) + assert.Contains(t, err.Error(), "host") }) t.Run("Valid redirect URL with path", func(t *testing.T) { @@ -564,8 +572,8 @@ func TestIsValidWebAuthRedirectURL(t *testing.T) { } redirectURL := "https://example.com/mattermost/oauth/callback" - result := IsValidWebAuthRedirectURL(config, redirectURL) - assert.True(t, result) + err := ValidateWebAuthRedirectUrl(config, redirectURL) + require.NoError(t, err) }) t.Run("Valid redirect URL with query parameters", func(t *testing.T) { @@ -576,8 +584,8 @@ func TestIsValidWebAuthRedirectURL(t *testing.T) { } redirectURL := "https://example.com/oauth/callback?state=abc123&code=def456" - result := IsValidWebAuthRedirectURL(config, redirectURL) - assert.True(t, result) + err := ValidateWebAuthRedirectUrl(config, redirectURL) + require.NoError(t, err) }) t.Run("Valid redirect URL with fragment", func(t *testing.T) { @@ -588,8 +596,8 @@ func TestIsValidWebAuthRedirectURL(t *testing.T) { } redirectURL := "https://example.com/oauth/callback#token=abc123" - result := IsValidWebAuthRedirectURL(config, redirectURL) - assert.True(t, result) + err := ValidateWebAuthRedirectUrl(config, redirectURL) + require.NoError(t, err) }) t.Run("Invalid redirect URL with @ symbol in host", func(t *testing.T) { @@ -600,7 +608,8 @@ func TestIsValidWebAuthRedirectURL(t *testing.T) { } redirectURL := "https://qa-release.test.mattermost.cloud@example.com/oauth/callback" - result := IsValidWebAuthRedirectURL(config, redirectURL) - assert.False(t, result) + err := ValidateWebAuthRedirectUrl(config, redirectURL) + require.Error(t, err) + assert.Contains(t, err.Error(), "host") }) } diff --git a/server/channels/web/context.go b/server/channels/web/context.go index 608f23031be..9cb57428ab7 100644 --- a/server/channels/web/context.go +++ b/server/channels/web/context.go @@ -768,6 +768,17 @@ func (c *Context) RequireContentReviewerId() *Context { return c } +func (c *Context) RequireRecapId() *Context { + if c.Err != nil { + return c + } + + if !model.IsValidId(c.Params.RecapId) { + c.SetInvalidURLParam("recap_id") + } + return c +} + func (c *Context) GetRemoteID(r *http.Request) string { return r.Header.Get(model.HeaderRemoteclusterId) } diff --git a/server/channels/web/oauth.go b/server/channels/web/oauth.go index 5f217fc3c71..37be49847e3 100644 --- a/server/channels/web/oauth.go +++ b/server/channels/web/oauth.go @@ -403,7 +403,8 @@ func completeOAuth(c *Context, w http.ResponseWriter, r *http.Request) { redirectURL = utils.AppendQueryParamsToURL(c.GetSiteURLHeader()+"/login/desktop", queryString) auditRec.Success() - c.LogAudit("success") + auditRec.Actor.UserId = user.Id + c.LogAuditWithUserId(user.Id, "authenticated") w.Header().Set("Content-Type", "text/html; charset=utf-8") http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect) @@ -464,9 +465,11 @@ func loginWithOAuth(c *Context, w http.ResponseWriter, r *http.Request) { redirectURL := r.URL.Query().Get("redirect_to") desktopToken := r.URL.Query().Get("desktop_token") - if redirectURL != "" && !utils.IsValidWebAuthRedirectURL(c.App.Config(), redirectURL) { - c.Err = model.NewAppError("loginWithOAuth", "api.invalid_redirect_url", nil, "", http.StatusBadRequest) - return + if redirectURL != "" { + if err := utils.ValidateWebAuthRedirectUrl(c.App.Config(), redirectURL); err != nil { + c.Err = model.NewAppError("loginWithOAuth", "api.invalid_redirect_url", nil, err.Error(), http.StatusBadRequest) + return + } } auditRec := c.MakeAuditRecord(model.AuditEventLoginWithOAuth, model.AuditStatusFail) diff --git a/server/channels/web/params.go b/server/channels/web/params.go index 3d62e49796b..220eb746fd6 100644 --- a/server/channels/web/params.go +++ b/server/channels/web/params.go @@ -55,6 +55,7 @@ type Params struct { Service string JobId string JobType string + RecapId string ActionId string RoleId string RoleName string @@ -169,6 +170,7 @@ func ParamsFromRequest(r *http.Request) *Params { params.EmojiName = props["emoji_name"] params.JobId = props["job_id"] params.JobType = props["job_type"] + params.RecapId = props["recap_id"] params.ActionId = props["action_id"] params.RoleId = props["role_id"] params.RoleName = props["role_name"] diff --git a/server/channels/wsapi/user.go b/server/channels/wsapi/user.go index 9761fec0b40..2087064a16c 100644 --- a/server/channels/wsapi/user.go +++ b/server/channels/wsapi/user.go @@ -27,7 +27,7 @@ func (api *API) userTyping(req *model.WebSocketRequest) (map[string]any, *model. return nil, NewInvalidWebSocketParamError(req.Action, "channel_id") } - if !api.App.SessionHasPermissionToChannel(request.EmptyContext(api.App.Log()), req.Session, channelId, model.PermissionCreatePost) { + if hasPermission, _ := api.App.SessionHasPermissionToChannel(request.EmptyContext(api.App.Log()), req.Session, channelId, model.PermissionCreatePost); !hasPermission { return nil, NewInvalidWebSocketParamError(req.Action, "channel_id") } diff --git a/server/cmd/mmctl/client/client.go b/server/cmd/mmctl/client/client.go index a97d6318e12..8c5c247b90a 100644 --- a/server/cmd/mmctl/client/client.go +++ b/server/cmd/mmctl/client/client.go @@ -28,7 +28,7 @@ type Client interface { PatchChannel(ctx context.Context, channelID string, patch *model.ChannelPatch) (*model.Channel, *model.Response, error) GetChannelByName(ctx context.Context, channelName, teamID string, etag string) (*model.Channel, *model.Response, error) GetChannelByNameIncludeDeleted(ctx context.Context, channelName, teamID string, etag string) (*model.Channel, *model.Response, error) - GetChannel(ctx context.Context, channelID, etag string) (*model.Channel, *model.Response, error) + GetChannel(ctx context.Context, channelID string) (*model.Channel, *model.Response, error) GetTeam(ctx context.Context, teamID, etag string) (*model.Team, *model.Response, error) GetTeamByName(ctx context.Context, name, etag string) (*model.Team, *model.Response, error) GetAllTeams(ctx context.Context, etag string, page int, perPage int) ([]*model.Team, *model.Response, error) diff --git a/server/cmd/mmctl/commands/channel_e2e_test.go b/server/cmd/mmctl/commands/channel_e2e_test.go index 6ab0056d651..ff664593747 100644 --- a/server/cmd/mmctl/commands/channel_e2e_test.go +++ b/server/cmd/mmctl/commands/channel_e2e_test.go @@ -5,7 +5,6 @@ package commands import ( "fmt" - "sort" "github.com/hashicorp/go-multierror" "github.com/mattermost/mattermost/server/public/model" @@ -27,10 +26,7 @@ func (s *MmctlE2ETestSuite) TestListChannelsCmdF() { got = append(got, lines[i].(*model.Channel).Name) } - sort.Strings(want) - sort.Strings(got) - - s.Equal(want, got) + s.ElementsMatch(want, got) } s.Run("List channels/Client", func() { diff --git a/server/cmd/mmctl/commands/channel_test.go b/server/cmd/mmctl/commands/channel_test.go index d21c4111393..d0c02537a48 100644 --- a/server/cmd/mmctl/commands/channel_test.go +++ b/server/cmd/mmctl/commands/channel_test.go @@ -192,7 +192,7 @@ func (s *MmctlUnitTestSuite) TestModifyChannelCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), args[0], ""). + GetChannel(context.TODO(), args[0]). Return(nil, &model.Response{}, errors.New("")). Times(1) @@ -246,7 +246,7 @@ func (s *MmctlUnitTestSuite) TestModifyChannelCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), args[0], ""). + GetChannel(context.TODO(), args[0]). Return(channel, &model.Response{}, nil). Times(1) @@ -271,7 +271,7 @@ func (s *MmctlUnitTestSuite) TestModifyChannelCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), args[0], ""). + GetChannel(context.TODO(), args[0]). Return(channel, &model.Response{}, nil). Times(1) @@ -298,7 +298,7 @@ func (s *MmctlUnitTestSuite) TestModifyChannelCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), args[0], ""). + GetChannel(context.TODO(), args[0]). Return(channel, &model.Response{}, nil). Times(1) @@ -333,7 +333,7 @@ func (s *MmctlUnitTestSuite) TestModifyChannelCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), args[0], ""). + GetChannel(context.TODO(), args[0]). Return(channel, &model.Response{}, nil). Times(1) @@ -368,7 +368,7 @@ func (s *MmctlUnitTestSuite) TestModifyChannelCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), args[0], ""). + GetChannel(context.TODO(), args[0]). Return(channel, &model.Response{}, nil). Times(1) @@ -443,7 +443,7 @@ func (s *MmctlUnitTestSuite) TestArchiveChannelCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(&mockChannel, &model.Response{}, nil). Times(1) @@ -475,13 +475,13 @@ func (s *MmctlUnitTestSuite) TestArchiveChannelCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelArg1, ""). + GetChannel(context.TODO(), channelArg1). Return(&mockChannel1, &model.Response{}, nil). Times(1) s.client. EXPECT(). - GetChannel(context.TODO(), channelArg2, ""). + GetChannel(context.TODO(), channelArg2). Return(&mockChannel2, &model.Response{}, nil). Times(1) @@ -558,7 +558,7 @@ func (s *MmctlUnitTestSuite) TestArchiveChannelCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelArg, ""). + GetChannel(context.TODO(), channelArg). Return(nil, &model.Response{}, nil). Times(1) @@ -581,7 +581,7 @@ func (s *MmctlUnitTestSuite) TestArchiveChannelCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelArg, ""). + GetChannel(context.TODO(), channelArg). Return(nil, &model.Response{}, nil). Times(1) @@ -607,7 +607,7 @@ func (s *MmctlUnitTestSuite) TestArchiveChannelCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelArg, ""). + GetChannel(context.TODO(), channelArg). Return(&mockChannel, &model.Response{}, nil). Times(1) @@ -1702,7 +1702,7 @@ func (s *MmctlUnitTestSuite) TestUnarchiveChannelCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(&mockChannel, &model.Response{}, nil). Times(1) @@ -1734,13 +1734,13 @@ func (s *MmctlUnitTestSuite) TestUnarchiveChannelCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelArg1, ""). + GetChannel(context.TODO(), channelArg1). Return(&mockChannel1, &model.Response{}, nil). Times(1) s.client. EXPECT(). - GetChannel(context.TODO(), channelArg2, ""). + GetChannel(context.TODO(), channelArg2). Return(&mockChannel2, &model.Response{}, nil). Times(1) @@ -1816,7 +1816,7 @@ func (s *MmctlUnitTestSuite) TestUnarchiveChannelCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelArg, ""). + GetChannel(context.TODO(), channelArg). Return(nil, &model.Response{}, nil). Times(1) @@ -1839,7 +1839,7 @@ func (s *MmctlUnitTestSuite) TestUnarchiveChannelCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelArg, ""). + GetChannel(context.TODO(), channelArg). Return(nil, &model.Response{}, nil). Times(1) @@ -1863,7 +1863,7 @@ func (s *MmctlUnitTestSuite) TestUnarchiveChannelCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(&mockChannel, &model.Response{}, nil). Times(1) @@ -1975,7 +1975,7 @@ func (s *MmctlUnitTestSuite) TestRenameChannelCmd() { s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(nil, &model.Response{}, nil). Times(1) @@ -2000,7 +2000,7 @@ func (s *MmctlUnitTestSuite) TestRenameChannelCmd() { s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(nil, &model.Response{}, nil). Times(1) @@ -2078,7 +2078,7 @@ func (s *MmctlUnitTestSuite) TestRenameChannelCmd() { s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(nil, &model.Response{}, nil). Times(1) @@ -2245,7 +2245,7 @@ func (s *MmctlUnitTestSuite) TestRenameChannelCmd() { s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(foundChannel, &model.Response{}, nil). Times(1) @@ -2512,7 +2512,7 @@ func (s *MmctlUnitTestSuite) TestMoveChannelCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelID, ""). + GetChannel(context.TODO(), channelID). Return(nil, &model.Response{}, errors.New("")). Times(1) @@ -2543,7 +2543,7 @@ func (s *MmctlUnitTestSuite) TestMoveChannelCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelID, ""). + GetChannel(context.TODO(), channelID). Return(&model.Channel{Id: channelID, Name: "some-name"}, &model.Response{}, nil). Times(1) @@ -2838,7 +2838,7 @@ func (s *MmctlUnitTestSuite) TestDeleteChannelsCmd() { s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(nil, &model.Response{}, nil). Times(1) @@ -2927,7 +2927,7 @@ func (s *MmctlUnitTestSuite) TestDeleteChannelsCmd() { s.client. EXPECT(). - GetChannel(context.TODO(), channelNameDoesNotExist, ""). + GetChannel(context.TODO(), channelNameDoesNotExist). Return(nil, &model.Response{}, mockError). Times(1) diff --git a/server/cmd/mmctl/commands/channel_users_test.go b/server/cmd/mmctl/commands/channel_users_test.go index 30b208195c0..4dfec5b88fd 100644 --- a/server/cmd/mmctl/commands/channel_users_test.go +++ b/server/cmd/mmctl/commands/channel_users_test.go @@ -87,7 +87,7 @@ func (s *MmctlUnitTestSuite) TestChannelUsersAddCmdF() { Times(1) s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(nil, &model.Response{}, nil). Times(1) diff --git a/server/cmd/mmctl/commands/channelargs.go b/server/cmd/mmctl/commands/channelargs.go index 9d83cfe705a..6a2bc89a54e 100644 --- a/server/cmd/mmctl/commands/channelargs.go +++ b/server/cmd/mmctl/commands/channelargs.go @@ -55,7 +55,7 @@ func getChannelFromChannelArg(c client.Client, channelArg string) *model.Channel } if channel == nil { - channel, _, _ = c.GetChannel(context.TODO(), channelPart, "") + channel, _, _ = c.GetChannel(context.TODO(), channelPart) } return channel @@ -109,7 +109,7 @@ func getChannelFromArg(c client.Client, arg string) (*model.Channel, error) { return channel, nil } var err error - channel, response, err = c.GetChannel(context.TODO(), channelArg, "") + channel, response, err = c.GetChannel(context.TODO(), channelArg) if err != nil { nErr := ExtractErrorFromResponse(response, err) var nfErr *NotFoundError diff --git a/server/cmd/mmctl/commands/channelargs_test.go b/server/cmd/mmctl/commands/channelargs_test.go index 1e03185865c..8f9a8417af0 100644 --- a/server/cmd/mmctl/commands/channelargs_test.go +++ b/server/cmd/mmctl/commands/channelargs_test.go @@ -19,7 +19,7 @@ func (s *MmctlUnitTestSuite) TestGetChannelArgs() { s.client. EXPECT(). - GetChannel(context.TODO(), notFoundChannel, ""). + GetChannel(context.TODO(), notFoundChannel). Return(nil, &model.Response{StatusCode: http.StatusNotFound}, notFoundErr). Times(1) @@ -34,7 +34,7 @@ func (s *MmctlUnitTestSuite) TestGetChannelArgs() { s.client. EXPECT(). - GetChannel(context.TODO(), badRequestChannel, ""). + GetChannel(context.TODO(), badRequestChannel). Return(nil, &model.Response{StatusCode: http.StatusBadRequest}, badRequestErr). Times(1) @@ -49,7 +49,7 @@ func (s *MmctlUnitTestSuite) TestGetChannelArgs() { s.client. EXPECT(). - GetChannel(context.TODO(), forbidden, ""). + GetChannel(context.TODO(), forbidden). Return(nil, &model.Response{StatusCode: http.StatusForbidden}, forbiddenErr). Times(1) @@ -64,7 +64,7 @@ func (s *MmctlUnitTestSuite) TestGetChannelArgs() { s.client. EXPECT(). - GetChannel(context.TODO(), errChannel, ""). + GetChannel(context.TODO(), errChannel). Return(nil, &model.Response{StatusCode: http.StatusInternalServerError}, internalServerErrorErr). Times(1) @@ -79,7 +79,7 @@ func (s *MmctlUnitTestSuite) TestGetChannelArgs() { s.client. EXPECT(). - GetChannel(context.TODO(), successID, ""). + GetChannel(context.TODO(), successID). Return(successChannel, nil, nil). Times(1) diff --git a/server/cmd/mmctl/commands/config.go b/server/cmd/mmctl/commands/config.go index add760f203d..b522eb5c3f7 100644 --- a/server/cmd/mmctl/commands/config.go +++ b/server/cmd/mmctl/commands/config.go @@ -45,7 +45,7 @@ var ConfigSetCmd = &cobra.Command{ Use: "set", Short: "Set config setting", Long: "Sets the value of a config setting by its name in dot notation. Accepts multiple values for array settings", - Example: "config set SqlSettings.DriverName mysql\nconfig set SqlSettings.DataSourceReplicas \"replica1\" \"replica2\"", + Example: "config set SqlSettings.DriverName postgres\nconfig set SqlSettings.DataSourceReplicas \"replica1\" \"replica2\"", Args: cobra.MinimumNArgs(2), RunE: withClient(configSetCmdF), } diff --git a/server/cmd/mmctl/commands/config_e2e_test.go b/server/cmd/mmctl/commands/config_e2e_test.go index 46a5e7b6130..4fcc9a1aa8f 100644 --- a/server/cmd/mmctl/commands/config_e2e_test.go +++ b/server/cmd/mmctl/commands/config_e2e_test.go @@ -163,6 +163,34 @@ func (s *MmctlE2ETestSuite) TestConfigSetCmd() { s.Require().Len(printer.GetLines(), 0) s.Require().Len(printer.GetErrorLines(), 0) }) + + s.Run("ImportSettings.Directory cannot be set via API but can via local mode", func() { + printer.Clean() + originalDir := *s.th.App.Config().ImportSettings.Directory + + args := []string{"ImportSettings.Directory", "./api-blocked-import"} + err := configSetCmdF(s.th.SystemAdminClient, &cobra.Command{}, args) + s.Require().NotNil(err) + s.Require().Contains(err.Error(), "not allowed due to security reasons") + s.Require().Len(printer.GetLines(), 0) + + // Verify value didn't change + s.Require().Equal(originalDir, *s.th.App.Config().ImportSettings.Directory) + + printer.Clean() + args = []string{"ImportSettings.Directory", "./local-allowed-import"} + err = configSetCmdF(s.th.LocalClient, &cobra.Command{}, args) + s.Require().Nil(err) + s.Require().Len(printer.GetLines(), 1) + config, ok := printer.GetLines()[0].(*model.Config) + s.Require().True(ok) + s.Require().Equal("./local-allowed-import", *config.ImportSettings.Directory) + + // Reset to original + s.th.App.UpdateConfig(func(cfg *model.Config) { + cfg.ImportSettings.Directory = &originalDir + }) + }) } func (s *MmctlE2ETestSuite) TestConfigEditCmd() { diff --git a/server/cmd/mmctl/commands/group_test.go b/server/cmd/mmctl/commands/group_test.go index a639da5e9a8..4c5f32229a8 100644 --- a/server/cmd/mmctl/commands/group_test.go +++ b/server/cmd/mmctl/commands/group_test.go @@ -454,7 +454,7 @@ func (s *MmctlUnitTestSuite) TestChannelGroupListCmd() { s.client. EXPECT(). - GetChannel(context.TODO(), channelID, ""). + GetChannel(context.TODO(), channelID). Return(nil, &model.Response{}, nil). Times(1) @@ -560,7 +560,7 @@ func (s *MmctlUnitTestSuite) TestChannelGroupListCmd() { s.client. EXPECT(). - GetChannel(context.TODO(), channelID, ""). + GetChannel(context.TODO(), channelID). Return(nil, &model.Response{}, mockError). Times(1) @@ -853,7 +853,7 @@ func (s *MmctlUnitTestSuite) TestChannelGroupStatusCmd() { s.client. EXPECT(). - GetChannel(context.TODO(), channelID, ""). + GetChannel(context.TODO(), channelID). Return(nil, &model.Response{}, nil). Times(1) @@ -1057,7 +1057,7 @@ func (s *MmctlUnitTestSuite) TestChannelGroupEnableCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelPart, ""). + GetChannel(context.TODO(), channelPart). Return(nil, &model.Response{}, mockError). Times(1) @@ -1247,7 +1247,7 @@ func (s *MmctlUnitTestSuite) TestChannelGroupEnableCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelPart, ""). + GetChannel(context.TODO(), channelPart). Return(nil, &model.Response{}, nil). Times(1) @@ -1290,7 +1290,7 @@ func (s *MmctlUnitTestSuite) TestChannelGroupEnableCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelPart, ""). + GetChannel(context.TODO(), channelPart). Return(&mockChannel, &model.Response{}, nil). Times(1) @@ -1395,7 +1395,7 @@ func (s *MmctlUnitTestSuite) TestChannelGroupDisableCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelPart, ""). + GetChannel(context.TODO(), channelPart). Return(nil, &model.Response{}, nil). Times(1) @@ -1496,7 +1496,7 @@ func (s *MmctlUnitTestSuite) TestChannelGroupDisableCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelPart, ""). + GetChannel(context.TODO(), channelPart). Return(nil, &model.Response{}, mockError). Times(1) diff --git a/server/cmd/mmctl/commands/post_e2e_test.go b/server/cmd/mmctl/commands/post_e2e_test.go index a738499e245..44fd6821ebc 100644 --- a/server/cmd/mmctl/commands/post_e2e_test.go +++ b/server/cmd/mmctl/commands/post_e2e_test.go @@ -22,10 +22,10 @@ func (s *MmctlE2ETestSuite) TestPostListCmd() { channel, err := s.th.App.CreateChannel(s.th.Context, &model.Channel{Name: channelName, DisplayName: channelDisplayName, Type: model.ChannelTypePrivate, TeamId: s.th.BasicTeam.Id}, false) s.Require().Nil(err) - post1, err := s.th.App.CreatePost(s.th.Context, &model.Post{Message: model.NewRandomString(15), UserId: s.th.BasicUser.Id, ChannelId: channel.Id}, channel, model.CreatePostFlags{}) + post1, _, err := s.th.App.CreatePost(s.th.Context, &model.Post{Message: model.NewRandomString(15), UserId: s.th.BasicUser.Id, ChannelId: channel.Id}, channel, model.CreatePostFlags{}) s.Require().Nil(err) - post2, err := s.th.App.CreatePost(s.th.Context, &model.Post{Message: model.NewRandomString(15), UserId: s.th.BasicUser.Id, ChannelId: channel.Id}, channel, model.CreatePostFlags{}) + post2, _, err := s.th.App.CreatePost(s.th.Context, &model.Post{Message: model.NewRandomString(15), UserId: s.th.BasicUser.Id, ChannelId: channel.Id}, channel, model.CreatePostFlags{}) s.Require().Nil(err) return channelName, post1, post2 diff --git a/server/cmd/mmctl/commands/post_test.go b/server/cmd/mmctl/commands/post_test.go index 2c10b8c4524..931b4e96f5e 100644 --- a/server/cmd/mmctl/commands/post_test.go +++ b/server/cmd/mmctl/commands/post_test.go @@ -64,7 +64,7 @@ func (s *MmctlUnitTestSuite) TestPostCreateCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelArg, ""). + GetChannel(context.TODO(), channelArg). Return(&mockChannel, &model.Response{}, nil). Times(1) @@ -91,7 +91,7 @@ func (s *MmctlUnitTestSuite) TestPostCreateCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelArg, ""). + GetChannel(context.TODO(), channelArg). Return(&mockChannel, &model.Response{}, nil). Times(1) @@ -123,7 +123,7 @@ func (s *MmctlUnitTestSuite) TestPostCreateCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelArg, ""). + GetChannel(context.TODO(), channelArg). Return(&mockChannel, &model.Response{}, nil). Times(1) @@ -162,7 +162,7 @@ func (s *MmctlUnitTestSuite) TestPostListCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(&mockChannel, &model.Response{}, nil). Times(1) @@ -187,7 +187,7 @@ func (s *MmctlUnitTestSuite) TestPostListCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(&mockChannel, &model.Response{}, nil). Times(1) @@ -235,7 +235,7 @@ func (s *MmctlUnitTestSuite) TestPostListCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(&mockChannel, &model.Response{}, nil). Times(1) diff --git a/server/cmd/mmctl/commands/report_test.go b/server/cmd/mmctl/commands/report_test.go index 80328da7340..b3f3caf4a40 100644 --- a/server/cmd/mmctl/commands/report_test.go +++ b/server/cmd/mmctl/commands/report_test.go @@ -38,7 +38,7 @@ func (s *MmctlUnitTestSuite) TestReportPostsCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(&mockChannel, &model.Response{}, nil). Times(1) @@ -61,7 +61,7 @@ func (s *MmctlUnitTestSuite) TestReportPostsCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(&mockChannel, &model.Response{}, nil). Times(1) @@ -84,7 +84,7 @@ func (s *MmctlUnitTestSuite) TestReportPostsCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(&mockChannel, &model.Response{}, nil). Times(1) @@ -107,7 +107,7 @@ func (s *MmctlUnitTestSuite) TestReportPostsCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(&mockChannel, &model.Response{}, nil). Times(1) @@ -131,7 +131,7 @@ func (s *MmctlUnitTestSuite) TestReportPostsCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(&mockChannel, &model.Response{}, nil). Times(1) @@ -179,7 +179,7 @@ func (s *MmctlUnitTestSuite) TestReportPostsCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(&mockChannel, &model.Response{}, nil). Times(1) @@ -229,7 +229,7 @@ func (s *MmctlUnitTestSuite) TestReportPostsCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(&mockChannel, &model.Response{}, nil). Times(1) @@ -278,7 +278,7 @@ func (s *MmctlUnitTestSuite) TestReportPostsCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(&mockChannel, &model.Response{}, nil). Times(1) @@ -328,7 +328,7 @@ func (s *MmctlUnitTestSuite) TestReportPostsCmdF() { s.client. EXPECT(). - GetChannel(context.TODO(), channelName, ""). + GetChannel(context.TODO(), channelName). Return(&mockChannel, &model.Response{}, nil). Times(1) diff --git a/server/cmd/mmctl/commands/webhook_test.go b/server/cmd/mmctl/commands/webhook_test.go index c663741d2bb..dc015607581 100644 --- a/server/cmd/mmctl/commands/webhook_test.go +++ b/server/cmd/mmctl/commands/webhook_test.go @@ -265,7 +265,7 @@ func (s *MmctlUnitTestSuite) TestCreateIncomingWebhookCmd() { s.client. EXPECT(). - GetChannel(context.TODO(), channelID, ""). + GetChannel(context.TODO(), channelID). Return(&mockChannel, &model.Response{}, nil). Times(1) @@ -309,7 +309,7 @@ func (s *MmctlUnitTestSuite) TestCreateIncomingWebhookCmd() { s.client. EXPECT(). - GetChannel(context.TODO(), channelID, ""). + GetChannel(context.TODO(), channelID). Return(&mockChannel, &model.Response{}, nil). Times(1) diff --git a/server/cmd/mmctl/docs/mmctl_config_set.rst b/server/cmd/mmctl/docs/mmctl_config_set.rst index 1193d82ba3f..3571478fa9a 100644 --- a/server/cmd/mmctl/docs/mmctl_config_set.rst +++ b/server/cmd/mmctl/docs/mmctl_config_set.rst @@ -20,7 +20,7 @@ Examples :: - config set SqlSettings.DriverName mysql + config set SqlSettings.DriverName postgres config set SqlSettings.DataSourceReplicas "replica1" "replica2" Options diff --git a/server/cmd/mmctl/mocks/client_mock.go b/server/cmd/mmctl/mocks/client_mock.go index 9840f19a595..a1662e225a1 100644 --- a/server/cmd/mmctl/mocks/client_mock.go +++ b/server/cmd/mmctl/mocks/client_mock.go @@ -715,9 +715,9 @@ func (mr *MockClientMockRecorder) GetBotsOrphaned(arg0, arg1, arg2, arg3 interfa } // GetChannel mocks base method. -func (m *MockClient) GetChannel(arg0 context.Context, arg1, arg2 string) (*model.Channel, *model.Response, error) { +func (m *MockClient) GetChannel(arg0 context.Context, arg1 string) (*model.Channel, *model.Response, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "GetChannel", arg0, arg1, arg2) + ret := m.ctrl.Call(m, "GetChannel", arg0, arg1) ret0, _ := ret[0].(*model.Channel) ret1, _ := ret[1].(*model.Response) ret2, _ := ret[2].(error) @@ -725,9 +725,9 @@ func (m *MockClient) GetChannel(arg0 context.Context, arg1, arg2 string) (*model } // GetChannel indicates an expected call of GetChannel. -func (mr *MockClientMockRecorder) GetChannel(arg0, arg1, arg2 interface{}) *gomock.Call { +func (mr *MockClientMockRecorder) GetChannel(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannel", reflect.TypeOf((*MockClient)(nil).GetChannel), arg0, arg1, arg2) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetChannel", reflect.TypeOf((*MockClient)(nil).GetChannel), arg0, arg1) } // GetChannelByName mocks base method. diff --git a/server/config/database.go b/server/config/database.go index 82619039ac1..7708b43eb52 100644 --- a/server/config/database.go +++ b/server/config/database.go @@ -84,8 +84,6 @@ func NewDatabaseStore(dsn string) (ds *DatabaseStore, err error) { } // initializeConfigurationsTable ensures the requisite tables in place to form the backing store. -// -// Uses MEDIUMTEXT on MySQL, and TEXT on sane databases. func (ds *DatabaseStore) initializeConfigurationsTable() error { assetsList, err := assets.ReadDir(filepath.Join("migrations", ds.driverName)) if err != nil { @@ -132,18 +130,9 @@ func (ds *DatabaseStore) initializeConfigurationsTable() error { return engine.ApplyAll() } -// parseDSN splits up a connection string into a driver name and data source name. +// parseDSN parses a PostgreSQL connection string and validates the scheme. // -// For example: -// -// mysql://mmuser:mostest@localhost:5432/mattermost_test -// -// returns -// -// driverName = mysql -// dataSourceName = mmuser:mostest@localhost:5432/mattermost_test -// -// By contrast, a Postgres DSN is returned unmodified. +// Accepts postgres:// or postgresql:// schemes and returns the DSN unmodified. func parseDSN(dsn string) (string, string, error) { // Treat the DSN as the URL that it is. s := strings.SplitN(dsn, "://", 2) diff --git a/server/config/database_test.go b/server/config/database_test.go index 7fb8532d09e..34356a87cca 100644 --- a/server/config/database_test.go +++ b/server/config/database_test.go @@ -209,7 +209,7 @@ func TestDatabaseStoreNew(t *testing.T) { _, err := NewDatabaseStore("") require.Error(t, err) - _, err = NewDatabaseStore("mysql") + _, err = NewDatabaseStore("postgres") require.Error(t, err) }) @@ -1054,17 +1054,10 @@ func TestDatabaseStoreString(t *testing.T) { require.NotNil(t, ds) defer ds.Close() - if *mainHelper.GetSQLSettings().DriverName == "postgres" { - maskedDSN := ds.String() - assert.True(t, strings.HasPrefix(maskedDSN, "postgres://")) - assert.False(t, strings.Contains(maskedDSN, "mmuser")) - assert.False(t, strings.Contains(maskedDSN, "mostest")) - } else { - maskedDSN := ds.String() - assert.False(t, strings.HasPrefix(maskedDSN, "mysql://")) - assert.False(t, strings.Contains(maskedDSN, "mmuser")) - assert.False(t, strings.Contains(maskedDSN, "mostest")) - } + maskedDSN := ds.String() + assert.True(t, strings.HasPrefix(maskedDSN, "postgres://")) + assert.False(t, strings.Contains(maskedDSN, "mmuser")) + assert.False(t, strings.Contains(maskedDSN, "mostest")) } func TestCleanUp(t *testing.T) { diff --git a/server/config/migrate_test.go b/server/config/migrate_test.go index 3941914a4a3..2e55f09d3c6 100644 --- a/server/config/migrate_test.go +++ b/server/config/migrate_test.go @@ -64,10 +64,10 @@ func TestMigrate(t *testing.T) { files[4], } cfg.SqlSettings.DataSourceReplicas = []string{ - "mysql://mmuser:password@tcp(replicahost:3306)/mattermost", + "postgres://mmuser:password@replicahost:5432/mattermost", } cfg.SqlSettings.DataSourceSearchReplicas = []string{ - "mysql://mmuser:password@tcp(searchreplicahost:3306)/mattermost", + "postgres://mmuser:password@searchreplicahost:5432/mattermost", } _, _, err := source.Set(cfg) diff --git a/server/config/utils.go b/server/config/utils.go index cb6b645abc6..2a104522b8f 100644 --- a/server/config/utils.go +++ b/server/config/utils.go @@ -169,8 +169,7 @@ func Merge(cfg *model.Config, patch *model.Config, mergeConfig *utils.MergeConfi } func IsDatabaseDSN(dsn string) bool { - return strings.HasPrefix(dsn, "mysql://") || - strings.HasPrefix(dsn, "postgres://") || + return strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") } diff --git a/server/config/utils_test.go b/server/config/utils_test.go index dbd6a0ede14..2e676b3b509 100644 --- a/server/config/utils_test.go +++ b/server/config/utils_test.go @@ -168,11 +168,6 @@ func TestIsDatabaseDSN(t *testing.T) { DSN string Expected bool }{ - { - Name: "Mysql DSN", - DSN: "mysql://localhost", - Expected: true, - }, { Name: "Postgresql 'postgres' DSN", DSN: "postgres://localhost", @@ -231,7 +226,6 @@ func TestIsJSONMap(t *testing.T) { {name: "array json", data: `["test1", "test2"]`, want: false}, {name: "bad json", data: `{huh?}`, want: false}, {name: "filename", data: "/tmp/logger.conf", want: false}, - {name: "mysql dsn", data: "mysql://mmuser:@tcp(localhost:3306)/mattermost?charset=utf8mb4,utf8&readTimeout=30s", want: false}, {name: "postgres dsn", data: "postgres://mmuser:passwordlocalhost:5432/mattermost?sslmode=disable&connect_timeout=10", want: false}, } for _, tt := range tests { diff --git a/server/go.mod b/server/go.mod index 4510907c6e5..3761f8deb5f 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,6 +1,6 @@ module github.com/mattermost/mattermost/server/v8 -go 1.24.6 +go 1.24.11 require ( code.sajari.com/docconv/v2 v2.0.0-pre.4 @@ -51,7 +51,6 @@ require ( github.com/mholt/archives v0.1.5 github.com/microcosm-cc/bluemonday v1.0.27 github.com/minio/minio-go/v7 v7.0.95 - github.com/oov/psd v0.0.0-20220121172623-5db5eafcecbb github.com/opensearch-project/opensearch-go/v4 v4.5.0 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.2 @@ -132,7 +131,6 @@ require ( github.com/google/btree v1.1.3 // indirect github.com/google/jsonschema-go v0.2.3 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gopherjs/gopherjs v1.17.2 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect diff --git a/server/go.sum b/server/go.sum index 4e07d849854..1719faab9df 100644 --- a/server/go.sum +++ b/server/go.sum @@ -282,8 +282,6 @@ github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE0 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= -github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= -github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= @@ -473,8 +471,6 @@ github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= -github.com/oov/psd v0.0.0-20220121172623-5db5eafcecbb h1:JF9kOhBBk4WPF7luXFu5yR+WgaFm9L/KiHJHhU9vDwA= -github.com/oov/psd v0.0.0-20220121172623-5db5eafcecbb/go.mod h1:GHI1bnmAcbp96z6LNfBJvtrjxhaXGkbsk967utPlvL8= github.com/opensearch-project/opensearch-go/v4 v4.5.0 h1:26XckmmF6MhlXt91Bu1yY6R51jy1Ns/C3XgIfvyeTRo= github.com/opensearch-project/opensearch-go/v4 v4.5.0/go.mod h1:VmFc7dqOEM3ZtLhrpleOzeq+cqUgNabqQG5gX0xId64= github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8= diff --git a/server/i18n/be.json b/server/i18n/be.json index 5cc87368dd5..c2425f72817 100644 --- a/server/i18n/be.json +++ b/server/i18n/be.json @@ -1,7 +1,7 @@ [ { "id": "api.slackimport.slack_add_users.created", - "translation": "\nКарыстальнiкi створаны:\n" + "translation": "\nСтвораны карыстальнікі:\n" }, { "id": "api.slackimport.slack_add_channels.added", @@ -9,11 +9,11 @@ }, { "id": "api.remote_cluster.invalid_topic.app_error", - "translation": "Няправільная тэма." + "translation": "Несапраўдная тэма." }, { "id": "api.remote_cluster.invalid_id.app_error", - "translation": "Няправільны ідэнтыфікатар." + "translation": "Несапраўдны ідэнтыфікатар." }, { "id": "api.post.send_notifications_and_forget.push_explicit_mention", @@ -25,7 +25,7 @@ }, { "id": "api.marshal_error", - "translation": "памылка маршалiнга" + "translation": "Немагчыма серыялізаваць." }, { "id": "api.io_error", @@ -37,27 +37,27 @@ }, { "id": "api.slackimport.slack_import.notes", - "translation": "\nНататкi:\n" + "translation": "\nЗаўвагі:\n" }, { "id": "api.command_shrug.name", - "translation": "паціснуць-плячыма" + "translation": "пацісканне плячыма" }, { "id": "api.command_shortcuts.name", - "translation": "цэтлікі" + "translation": "спалучэнні клавіш" }, { "id": "api.command_share.name", - "translation": "абагулiць-канал" + "translation": "агульны-канал" }, { "id": "api.command_remote.name", - "translation": "бяспечнае-злучэнне" + "translation": "бяспечнае злучэнне" }, { "id": "api.templates.questions_footer.title", - "translation": "Пытаннi?" + "translation": "Пытанні?" }, { "id": "api.templates.cloud_welcome_email.subject", @@ -73,11 +73,11 @@ }, { "id": "api.command_settings.name", - "translation": "наладкi" + "translation": "налады" }, { "id": "api.command_search.name", - "translation": "шукаць" + "translation": "пошук" }, { "id": "api.command_search.hint", @@ -85,7 +85,7 @@ }, { "id": "api.command_remove.name", - "translation": "выдалiць" + "translation": "выдаліць" }, { "id": "api.command_remove.hint", @@ -101,15 +101,15 @@ }, { "id": "api.command_online.name", - "translation": "анлайн" + "translation": "у сетцы" }, { "id": "api.command_offline.name", - "translation": "афлайн" + "translation": "не ў сетцы" }, { "id": "api.command_mute.name", - "translation": "не апавяшчаць" + "translation": "адключыць гук" }, { "id": "api.command_mute.hint", @@ -117,11 +117,11 @@ }, { "id": "api.command_msg.name", - "translation": "Паведамленне" + "translation": "паведамленне" }, { "id": "api.command_me.name", - "translation": "я" + "translation": "мне" }, { "id": "api.command_me.hint", @@ -129,7 +129,7 @@ }, { "id": "api.command_logout.name", - "translation": "выхад з сістэмы" + "translation": "выйсці" }, { "id": "api.command_leave.name", @@ -145,15 +145,15 @@ }, { "id": "api.command_join.hint", - "translation": "~[канал]" + "translation": "~[channel]" }, { "id": "api.command_invite.name", - "translation": "запрасiць" + "translation": "запрасіць" }, { "id": "api.command_help.name", - "translation": "дапамога" + "translation": "даведка" }, { "id": "api.command_groupmsg.name", @@ -165,7 +165,7 @@ }, { "id": "api.command_echo.name", - "translation": "рэха" + "translation": "эха" }, { "id": "api.command_dnd.name", @@ -173,7 +173,7 @@ }, { "id": "api.command_custom_status.name", - "translation": "стан" + "translation": "статус" }, { "id": "api.command_collapse.name", @@ -185,7 +185,7 @@ }, { "id": "api.command_code.hint", - "translation": "[тэкст]" + "translation": "[text]" }, { "id": "api.command_channel_rename.name", @@ -193,7 +193,7 @@ }, { "id": "api.command_channel_rename.hint", - "translation": "[тэкст]" + "translation": "[text]" }, { "id": "api.command_channel_purpose.name", @@ -201,7 +201,7 @@ }, { "id": "api.command_channel_purpose.hint", - "translation": "[тэкст]" + "translation": "[text]" }, { "id": "api.command_channel_header.name", @@ -209,19 +209,19 @@ }, { "id": "api.command_channel_header.hint", - "translation": "[тэкст]" + "translation": "[text]" }, { "id": "api.command_away.name", - "translation": "адышоў" + "translation": "не на месцы" }, { "id": "api.command.invite_people.name", - "translation": "запрасiць_людзей" + "translation": "запрасіць_людзей" }, { "id": "api.channel.create_default_channels.off_topic", - "translation": "Оффтоп" + "translation": "Па-за тэмай" }, { "id": "model.incoming_hook.username.app_error", @@ -229,7 +229,7 @@ }, { "id": "model.bot.is_valid.username.app_error", - "translation": "Няправільнае імя карыстальніка." + "translation": "Несапраўднае імя карыстальніка." }, { "id": "model.outgoing_hook.username.app_error", @@ -237,7 +237,7 @@ }, { "id": "api.incoming_webhook.invalid_username.app_error", - "translation": "Няправільнае імя карыстальніка." + "translation": "Несапраўднае імя карыстальніка." }, { "id": "api.email_batching.send_batched_email_notification.button", @@ -245,7 +245,7 @@ }, { "id": "api.command_remote.invite_password.help", - "translation": "Пароль запрашэння" + "translation": "Пароль для запрашэння" }, { "id": "api.command_remote.invitation_created", @@ -257,11 +257,11 @@ }, { "id": "api.command_invite.hint", - "translation": "@[імя карыстальніка] ~[канал]" + "translation": "@[username]... ~[channel]..." }, { "id": "api.command_groupmsg.hint", - "translation": "@[username1],@[username2] 'паведамленне'" + "translation": "@[username1],@[username2] 'message'" }, { "id": "api.command.invite_people.hint", @@ -269,11 +269,11 @@ }, { "id": "api.channel.create_default_channels.town_square", - "translation": "Гарадская плошча" + "translation": "Агульны канал" }, { "id": "web.error.unsupported_browser.system_browser_or", - "translation": "або" + "translation": "ці" }, { "id": "web.error.unsupported_browser.browser_title.safari", @@ -285,7 +285,7 @@ }, { "id": "system.message.name", - "translation": "Сiстэма" + "translation": "Сістэма" }, { "id": "error", @@ -293,7 +293,7 @@ }, { "id": "app.system.system_bot.bot_displayname", - "translation": "Сiстэма" + "translation": "Сістэма" }, { "id": "api.templates.welcome_body.app_download_button", @@ -301,23 +301,23 @@ }, { "id": "api.admin.saml.failure_save_idp_certificate_file.app_error", - "translation": "Немагчыма захаваць файл сертыфiката." + "translation": "Не атрымалася захаваць файл сертыфіката." }, { "id": "api.admin.add_certificate.saving.app_error", - "translation": "Немагчыма захаваць файл сертыфiката." + "translation": "Не атрымалася захаваць файл сертыфіката." }, { "id": "api.admin.add_certificate.parseform.app_error", - "translation": "Памылка сінтаксічнага аналізу шматфарматнага запыту" + "translation": "Памылка парсінгу multiform-запыту" }, { "id": "api.admin.add_certificate.open.app_error", - "translation": "Немагчыма адкрыць файл сертыфiката." + "translation": "Не атрымалася адкрыць файл сертыфіката." }, { "id": "api.admin.file_read_error", - "translation": "Памылка чытання лог файла." + "translation": "Памылка чытання лог-файла." }, { "id": "api.admin.ldap.not_available.app_error", @@ -329,11 +329,11 @@ }, { "id": "October", - "translation": "Кастрычнiк" + "translation": "Кастрычнік" }, { "id": "November", - "translation": "Лiстапад" + "translation": "Лістапад" }, { "id": "May", @@ -341,7 +341,7 @@ }, { "id": "March", - "translation": "Сакавiк" + "translation": "Сакавік" }, { "id": "June", @@ -349,7 +349,7 @@ }, { "id": "July", - "translation": "Лiпень" + "translation": "Ліпень" }, { "id": "January", @@ -365,39 +365,39 @@ }, { "id": "August", - "translation": "Жнiвень" + "translation": "Жнівень" }, { "id": "April", - "translation": "Красавiк" + "translation": "Красавік" }, { "id": "api.user.complete_switch_with_oauth.blank_email.app_error", - "translation": "Пусты ліст." + "translation": "Пусты адрас электроннай пошты." }, { "id": "api.user.authorize_oauth_user.invalid_state.app_error", - "translation": "Недапушчальны стан" + "translation": "Няправільны стан" }, { "id": "api.upload.upload_data.invalid_content_length", - "translation": "Недапушчальны Content-Length." + "translation": "Няправільная даўжыня зместу." }, { "id": "api.templates.welcome_body.button", - "translation": "Пацвердзіць электронную пошту" + "translation": "Праверыць электронную пошту" }, { "id": "api.templates.verify_body.button", - "translation": "Пацвердзіць электронную пошту" + "translation": "Праверыць электронную пошту" }, { "id": "api.templates.reset_body.button", - "translation": "Скінуць Пароль" + "translation": "Скід пароля" }, { "id": "api.templates.post_body.button", - "translation": "Прагляд Паведамлення" + "translation": "Адказаць у Mattermost" }, { "id": "api.templates.invite_body_footer.learn_more", @@ -405,11 +405,11 @@ }, { "id": "api.templates.invite_body.button", - "translation": "Далучыцца" + "translation": "Далучыцца зараз" }, { "id": "api.templates.email_organization", - "translation": "Даслаў " + "translation": "Адпраўлена " }, { "id": "api.templates.email_info2", @@ -417,7 +417,7 @@ }, { "id": "api.templates.email_change_verify_body.button", - "translation": "Пацвердзіць электронную пошту" + "translation": "Праверыць электронную пошту" }, { "id": "api.templates.cloud_welcome_email.button", @@ -425,87 +425,87 @@ }, { "id": "api.templates.cloud_welcome_email.app_market_place", - "translation": "Крама прыкладанняў." + "translation": "маркетплейсе праграм." }, { "id": "model.guest.is_valid.channels.app_error", - "translation": "Няправiльныя каналы." + "translation": "Няправільныя каналы." }, { "id": "model.guest.is_valid.channel.app_error", - "translation": "Няправiльны канал." + "translation": "Няправільны канал." }, { "id": "model.compliance.is_valid.id.app_error", - "translation": "Няправільны ідэнтыфікатар." + "translation": "Недапушчальны ідэнтыфікатар." }, { "id": "model.compliance.is_valid.desc.app_error", - "translation": "Няправільнае апісанне." + "translation": "Недапушчальнае апісанне." }, { "id": "model.command.is_valid.url.app_error", - "translation": "Няправільны URL." + "translation": "Недапушчальны URL." }, { "id": "model.command.is_valid.trigger.app_error", - "translation": "Няправільны трыгер." + "translation": "Недапушчальны трыгер." }, { "id": "model.command.is_valid.token.app_error", - "translation": "Няправiльны токен." + "translation": "Недапушчальны токен." }, { "id": "model.command.is_valid.method.app_error", - "translation": "Няправільны метад." + "translation": "Недапушчальны метад." }, { "id": "model.command.is_valid.id.app_error", - "translation": "Няправільны ідэнтыфікатар." + "translation": "Недапушчальны ідэнтыфікатар." }, { "id": "model.command.is_valid.display_name.app_error", - "translation": "Несапраўдны загаловак." - }, - { - "id": "model.command.is_valid.description.app_error", - "translation": "Няправільнае апісанне." - }, - { - "id": "model.command.is_valid.autocomplete_data.app_error", - "translation": "Несапраўдныя дадзеныя аўтазапаўнення" - }, - { - "id": "model.cluster.is_valid.id.app_error", - "translation": "Няправільны ідэнтыфікатар." - }, - { - "id": "model.channel.is_valid.type.app_error", - "translation": "Няправiльны тып." - }, - { - "id": "model.channel.is_valid.purpose.app_error", - "translation": "Несапраўдная мэта." - }, - { - "id": "model.channel.is_valid.id.app_error", - "translation": "Няправільны ідэнтыфікатар." - }, - { - "id": "model.channel.is_valid.header.app_error", "translation": "Няправільны загаловак." }, + { + "id": "model.command.is_valid.description.app_error", + "translation": "Несапраўднае апісанне." + }, + { + "id": "model.command.is_valid.autocomplete_data.app_error", + "translation": "Несапраўдныя AutocompleteData" + }, + { + "id": "model.cluster.is_valid.id.app_error", + "translation": "Несапраўдны ідэнтыфікатар." + }, + { + "id": "model.channel.is_valid.type.app_error", + "translation": "Несапраўдны тып." + }, + { + "id": "model.channel.is_valid.purpose.app_error", + "translation": "Несапраўднае прызначэнне." + }, + { + "id": "model.channel.is_valid.id.app_error", + "translation": "Несапраўдны ідэнтыфікатар." + }, + { + "id": "model.channel.is_valid.header.app_error", + "translation": "Несапраўдны загаловак." + }, { "id": "model.bot.is_valid.description.app_error", - "translation": "Няправільнае апісанне." + "translation": "Несапраўднае апісанне." }, { "id": "model.authorize.is_valid.state.app_error", - "translation": "Недапушчальны стан." + "translation": "Несапраўдны стан." }, { "id": "model.authorize.is_valid.scope.app_error", - "translation": "Несапраўдная сфера прымянення." + "translation": "Несапраўдны аб'ём." }, { "id": "ent.ldap.do_login.invalid_password.app_error", @@ -533,11 +533,11 @@ }, { "id": "api.post.create_post.root_id.app_error", - "translation": "Няправільны параметр RootId." + "translation": "Несапраўдны параметр RootId." }, { "id": "api.oauth.invalid_state_token.app_error", - "translation": "Няправільны токен стану." + "translation": "Несапраўдны токен стану." }, { "id": "api.oauth.get_access_token.missing_refresh_token.app_error", @@ -545,27 +545,27 @@ }, { "id": "api.oauth.get_access_token.missing_code.app_error", - "translation": "invalid_request: Адсутничае код." + "translation": "invalid_request: Адсутнічае код." }, { "id": "api.oauth.get_access_token.bad_grant.app_error", - "translation": "invalid_request: Няправільны grant_type." + "translation": "invalid_request: Несапраўдны grant_type." }, { "id": "api.oauth.get_access_token.bad_client_id.app_error", - "translation": "invalid_request: Няправільны client_id." + "translation": "invalid_request: Несапраўдны client_id." }, { "id": "api.license.add_license.invalid.app_error", - "translation": "Няправільны файл ліцэнзіі." + "translation": "Несапраўдны файл ліцэнзіі." }, { "id": "api.emoji.create.other_user.app_error", - "translation": "Няправільны ідэнтыфікатар карыстальніка." + "translation": "Некарэктны ідэнтыфікатар карыстальніка." }, { "id": "api.email_batching.send_batched_email_notification.messageButton", - "translation": "Праглядзець паведамленне" + "translation": "Праглядзець гэтае паведамленне" }, { "id": "api.context.json_encoding.app_error", @@ -573,11 +573,11 @@ }, { "id": "api.context.invitation_expired.error", - "translation": "Скончыўся тэрмін дзеяння запрашэння." + "translation": "Тэрмін дзеяння запрашэння скончыўся." }, { "id": "api.context.invalid_param.app_error", - "translation": "Няправільны параметр {{. Імя}}." + "translation": "Некарэктны параметр {{.Name}}." }, { "id": "api.command_share.available_actions", @@ -593,7 +593,7 @@ }, { "id": "api.command_me.desc", - "translation": "Зрабіце дзеянне" + "translation": "Выканаць дзеянне" }, { "id": "api.command_logout.desc", @@ -609,7 +609,7 @@ }, { "id": "api.channel.patch_channel_moderations.cache_invalidation.error", - "translation": "Памылка скіду кэша" + "translation": "Памылка пры несапраўднасці кэша" }, { "id": "api.back_to_app", @@ -617,11 +617,11 @@ }, { "id": "api.admin.saml.set_certificate_from_metadata.invalid_content_type.app_error", - "translation": "Няправільны тып кантэнту." + "translation": "Няправільны тып кантэнта." }, { "id": "api.admin.saml.set_certificate_from_metadata.missing_content_type.app_error", - "translation": "Адсутнічае тып кантэнту." + "translation": "Адсутнічае тып кантэнта." }, { "id": "model.incoming_hook.display_name.app_error", @@ -629,15 +629,15 @@ }, { "id": "api.admin.saml.set_certificate_from_metadata.invalid_body.app_error", - "translation": "Няправільны тэкст сэртыфіката." + "translation": "Несапраўдны тэкст сертыфіката." }, { "id": "web.incoming_webhook.invalid.app_error", - "translation": "Няправільны вэбхук." + "translation": "Несапраўдны вэбхук." }, { "id": "web.error.unsupported_browser.system_browser_make_default", - "translation": "Зрабіць па-змаўчанні" + "translation": "Зрабіць па змаўчанні" }, { "id": "web.error.unsupported_browser.open_system_browser.edge", @@ -645,35 +645,35 @@ }, { "id": "web.error.unsupported_browser.min_os_version.windows", - "translation": "Windows 8.1+" + "translation": "Windows 11+" }, { "id": "web.error.unsupported_browser.min_os_version.mac", - "translation": "macOS 10.14+" + "translation": "macOS 12+" }, { "id": "web.error.unsupported_browser.min_browser_version.safari", - "translation": "Версія 14.1+" + "translation": "Версія 17.4+" }, { "id": "web.error.unsupported_browser.min_browser_version.firefox", - "translation": "Версія 91+" + "translation": "Версія 140+" }, { "id": "web.error.unsupported_browser.min_browser_version.edge", - "translation": "Версія 44+" + "translation": "Версія 140+" }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Версія 89+" + "translation": "Версія 140+" }, { "id": "web.error.unsupported_browser.install_guide.windows", - "translation": "Інструкцыя па ўстаноўцы" + "translation": "Кіраўніцтва па ўстаноўцы" }, { "id": "web.error.unsupported_browser.install_guide.mac", - "translation": "Інструкцыя па ўстаноўцы" + "translation": "Кіраўніцтва па ўстаноўцы" }, { "id": "web.error.unsupported_browser.browser_title.edge", @@ -685,23 +685,23 @@ }, { "id": "model.user.is_valid.nickname.app_error", - "translation": "Няправільны нік." + "translation": "Несапраўдны мянушка." }, { "id": "model.user.is_valid.locale.app_error", - "translation": "Няправільная лакаль." + "translation": "Несапраўдная лакаль." }, { "id": "model.user.is_valid.email.app_error", - "translation": "Няправільны адрас электроннай пошты." + "translation": "Несапраўдны адрас электроннай пошты." }, { "id": "model.token.is_valid.size", - "translation": "Няправiльны токен." + "translation": "Няправільны токен." }, { "id": "model.team.is_valid.type.app_error", - "translation": "Няправiльны тып." + "translation": "Няправільны тып." }, { "id": "model.team.is_valid.name.app_error", @@ -709,11 +709,11 @@ }, { "id": "model.team.is_valid.id.app_error", - "translation": "Няправільны ідэнтыфікатар." + "translation": "Няправільны ID." }, { "id": "model.team.is_valid.email.app_error", - "translation": "Няправільны адрас электроннай пошты." + "translation": "Няправільны email." }, { "id": "model.team.is_valid.description.app_error", @@ -733,19 +733,19 @@ }, { "id": "model.post.is_valid.type.app_error", - "translation": "Няправiльны тып." + "translation": "Няправільны тып." }, { "id": "model.post.is_valid.props.app_error", - "translation": "Няправільныя рэквізіты." + "translation": "Няправільныя ўласцівасці." }, { "id": "model.post.is_valid.id.app_error", - "translation": "Няправільны ідэнтыфікатар." + "translation": "Няправільны ID." }, { "id": "model.post.is_valid.hashtags.app_error", - "translation": "Няправільныя хэштэгі." + "translation": "Няправільныя гештэгі." }, { "id": "model.post.is_valid.filenames.app_error", @@ -753,11 +753,11 @@ }, { "id": "model.outgoing_hook.is_valid.token.app_error", - "translation": "Няправiльны токен." + "translation": "Няправільны токен." }, { "id": "model.outgoing_hook.is_valid.id.app_error", - "translation": "Няправільны ідэнтыфікатар." + "translation": "Няправільны ID." }, { "id": "model.outgoing_hook.is_valid.display_name.app_error", @@ -769,7 +769,7 @@ }, { "id": "model.outgoing_hook.icon_url.app_error", - "translation": "Няправільны значок." + "translation": "Няправільная іконка." }, { "id": "model.oauth.is_valid.name.app_error", @@ -781,7 +781,7 @@ }, { "id": "model.incoming_hook.id.app_error", - "translation": "Няправільны ідэнтыфікатар." + "translation": "Няправільны ідэнтыфікатар: {{.Id}}." }, { "id": "model.incoming_hook.description.app_error", @@ -789,19 +789,19 @@ }, { "id": "model.guest.is_valid.emails.app_error", - "translation": "Няправільныя адрасы электроннай пошты." + "translation": "Няправільныя email." }, { "id": "model.guest.is_valid.email.app_error", - "translation": "Няправільны адрас электроннай пошты." + "translation": "Няправільны email." }, { "id": "app.channel.get_channels_by_ids.not_found.app_error", - "translation": "Ніводны канал не знойдзены." + "translation": "Канал не знойдзены." }, { "id": "app.admin.test_email.failure", - "translation": "Падключэнне не атрымалася: {{.Error}}" + "translation": "Злучэнне не было паспяховым: {{.Error}}" }, { "id": "api.websocket_handler.invalid_param.app_error", @@ -809,7 +809,7 @@ }, { "id": "api.templates.welcome_body.subTitle1", - "translation": "Дзякуй што далучыліся " + "translation": "Дзякуем, што далучыліся " }, { "id": "api.templates.welcome_body.serverURL", @@ -817,7 +817,7 @@ }, { "id": "api.templates.verify_body.subTitle1", - "translation": "Дзякуй што далучыліся " + "translation": "Дзякуем, што далучыліся " }, { "id": "api.templates.verify_body.serverURL", @@ -829,7 +829,7 @@ }, { "id": "api.templates.reset_body.title", - "translation": "Скіньце Свой Пароль" + "translation": "Скінуць ваш пароль" }, { "id": "api.templates.invite_body_footer.title", @@ -845,7 +845,7 @@ }, { "id": "api.status.user_not_found.app_error", - "translation": "Карыстальнік ня знойдзены." + "translation": "Карыстальнік не знойдзены." }, { "id": "api.push_notification.title.collapsed_threads", @@ -865,63 +865,63 @@ }, { "id": "api.post.create_webhook_post.creating.app_error", - "translation": "Памылка стварэння публікацыі." + "translation": "Памылка пры стварэнні паведамлення." }, { "id": "model.config.is_valid.saml_signature_algorithm.app_error", - "translation": "Няправільны алгарытм подпісу." + "translation": "Няправільны алгарытм падпісу." }, { "id": "model.config.is_valid.saml_canonical_algorithm.app_error", - "translation": "Некарэктны алгарытм кананізацыі." + "translation": "Няправільны алгарытм кананізацыі." }, { "id": "model.command_hook.root_id.app_error", - "translation": "Некарэктны каранёвы ідэнтыфікатар." + "translation": "Недапушчальны каранёвы ідэнтыфікатар." }, { "id": "model.command.is_valid.team_id.app_error", - "translation": "Няправільны ідэнтыфікатар каманды." + "translation": "Недапушчальны ідэнтыфікатар каманды." }, { "id": "model.command.is_valid.plugin_id.app_error", - "translation": "Няправільны ідэнтыфікатар плагіна." + "translation": "Недапушчальны ідэнтыфікатар плагіна." }, { "id": "model.channel_member.is_valid.notify_level.app_error", - "translation": "Недапушчальны ўзровень апавяшчэнняў." + "translation": "Несапраўдны ўзровень апавяшчэння." }, { "id": "model.channel_member.is_valid.channel_id.app_error", - "translation": "Няправiльны ідэнтыфікатар канала." + "translation": "Несапраўдны ідэнтыфікатар канала." }, { "id": "model.channel.is_valid.display_name.app_error", - "translation": "Недапушчальнае імя для адлюстравання." + "translation": "Несапраўднае імя для адлюстравання." }, { "id": "model.channel.is_valid.creator_id.app_error", - "translation": "Недапушчальны ідэнтыфікатар стваральніка." + "translation": "Несапраўдны ідэнтыфікатар стваральніка." }, { "id": "model.bot.is_valid.update_at.app_error", - "translation": "Недапушчальны параметр 'абноўлена ў'." + "translation": "Несапраўдны параметр \"абнаўленне ў\"." }, { "id": "model.bot.is_valid.creator_id.app_error", - "translation": "Недапушчальны ідэнтыфікатар стваральніка." + "translation": "Несапраўдны ідэнтыфікатар стваральніка." }, { "id": "model.bot.is_valid.create_at.app_error", - "translation": "Недапушчальны параметр 'створана ў'." + "translation": "Несапраўдны параметр \"створана ў\"." }, { "id": "model.authorize.is_valid.redirect_uri.app_error", - "translation": "Недапушчальны адрас рэдырэкту." + "translation": "Несапраўдны URI пераадрасацыі." }, { "id": "model.access.is_valid.redirect_uri.app_error", - "translation": "Недапушчальны адрас рэдырэкту." + "translation": "Несапраўдны URI пераадрасацыі." }, { "id": "mattermost.bulletin.subject", @@ -929,107 +929,107 @@ }, { "id": "ent.ldap_id_migrate.app_error", - "translation": "немагчыма мігрыраваць." + "translation": "немагчыма міграваць." }, { "id": "ent.ldap.syncronize.populate_syncables", - "translation": "памылка запаўнення сінхранізаваных" + "translation": "памылка запаўнення сінкаבלс" }, { "id": "app.import.validate_scheme_import_data.unknown_scheme.error", - "translation": "Невядомая вобласць ахопу схемы." + "translation": "Невядомы абсяг схемы." }, { "id": "api.web_socket_router.no_action.app_error", - "translation": "Няма websocket дзеяння." + "translation": "Няма дзеяння WebSocket." }, { "id": "api.post.do_action.action_integration.app_error", - "translation": "Памылка інтэграцыі дзеянняў." + "translation": "Памылка інтэграцыі дзеяння." }, { "id": "api.post.do_action.action_id.app_error", - "translation": "Несапраўдны ідэнтыфікатар дзеяння." + "translation": "Несапраўдны action id." }, { "id": "model.user.is_valid.last_name.app_error", - "translation": "Няправільная прозвішча." + "translation": "Несапраўднае прозвішча." }, { "id": "model.user.is_valid.id.app_error", - "translation": "Няправільны ідэнтыфікатар карыстальніка." + "translation": "Несапраўдны ідэнтыфікатар карыстальніка." }, { "id": "model.bot.is_valid.user_id.app_error", - "translation": "Няправільны ідэнтыфікатар карыстальніка." + "translation": "Несапраўдны ідэнтыфікатар карыстальніка." }, { "id": "model.outgoing_hook.is_valid.user_id.app_error", - "translation": "Няправільны ідэнтыфікатар карыстальніка." + "translation": "Няправільны ID карыстальніка." }, { "id": "model.reaction.is_valid.user_id.app_error", - "translation": "Няправільны ідэнтыфікатар карыстальніка." + "translation": "Няправільны ID карыстальніка." }, { "id": "model.websocket_client.connect_fail.app_error", - "translation": "Немагчыма падлучыцца да сервера WebSocket." + "translation": "Немагчыма падключыцца да сервера WebSocket." }, { "id": "model.utils.decode_json.app_error", - "translation": "немагчыма раскадаваць." + "translation": "Немагчыма дэкадаваць." }, { "id": "model.user_access_token.is_valid.user_id.app_error", - "translation": "Няправільны ідэнтыфікатар карыстальніка." + "translation": "Несапраўдны ідэнтыфікатар карыстальніка." }, { "id": "model.command_hook.user_id.app_error", - "translation": "Няправільны ідэнтыфікатар карыстальніка." + "translation": "Недапушчальны ідэнтыфікатар карыстальніка." }, { "id": "model.preference.is_valid.value.app_error", - "translation": "Занадта доўгае значэнне." + "translation": "Значэнне занадта доўгае." }, { "id": "model.authorize.is_valid.user_id.app_error", - "translation": "Няправільны ідэнтыфікатар карыстальніка." + "translation": "Несапраўдны ідэнтыфікатар карыстальніка." }, { "id": "app.import.validate_scheme_import_data.name_invalid.error", - "translation": "Няправільная назва схемы." + "translation": "Некарэктная назва схемы." }, { "id": "app.import.validate_scheme_import_data.description_invalid.error", - "translation": "Няправільнае апісанне схемы." + "translation": "Некарэктнае апісанне схемы." }, { "id": "app.import.validate_role_import_data.name_invalid.error", - "translation": "Няправільная назва ролі." + "translation": "Некарэктная назва ролі." }, { "id": "app.import.validate_role_import_data.description_invalid.error", - "translation": "Няправільнае апісанне ролі." + "translation": "Некарэктнае апісанне ролі." }, { "id": "app.import.generate_password.app_error", - "translation": "Памылка стварэння пароля." + "translation": "Памылка генерацыі пароля." }, { "id": "api.web_socket_router.bad_action.app_error", - "translation": "Невядомае дзеянне WebSocket." + "translation": "Невядомая дзея WebSocket." }, { "id": "api.user.update_password.failed.app_error", - "translation": "Не атрымалася абнавіць пароль." + "translation": "Абнаўленне пароля не атрымалася." }, { "id": "api.user.check_user_mfa.bad_code.app_error", - "translation": "Няправiльны MFA токен." + "translation": "Няправільны токен БФА." }, { "id": "api.user.authorize_oauth_user.token_failed.app_error", - "translation": "Запыт токена не атрымался." + "translation": "Запыт токена не атрымаўся." }, { "id": "api.user.authorize_oauth_user.missing.app_error", @@ -1037,7 +1037,7 @@ }, { "id": "api.user.authorize_oauth_user.bad_token.app_error", - "translation": "Памылковы тып токену." + "translation": "Няправільны тып токена." }, { "id": "api.templates.email_info3", @@ -1045,83 +1045,83 @@ }, { "id": "api.post.update_post.permissions_details.app_error", - "translation": "Ужо выдалены id={{.PostId}}." + "translation": "Ужо выдалена id={{.PostId}}." }, { "id": "api.templates.invite_body.title.reminder", - "translation": "Напамінальнік" + "translation": "Напамін" }, { "id": "model.preference.is_valid.id.app_error", - "translation": "Няправільны ідэнтыфікатар карыстальніка." + "translation": "Няправільны ID карыстальніка." }, { "id": "model.post.is_valid.user_id.app_error", - "translation": "Няправільны ідэнтыфікатар карыстальніка." + "translation": "Няправільны ID карыстальніка." }, { "id": "model.command_hook.command_id.app_error", - "translation": "Несапраўдны ідэнтыфікатар каманды." + "translation": "Недапушчальны ідэнтыфікатар каманды." }, { "id": "model.command_hook.channel_id.app_error", - "translation": "Няправiльны ідэнтыфікатар канала." + "translation": "Недапушчальны ідэнтыфікатар канала." }, { "id": "model.command.is_valid.user_id.app_error", - "translation": "Няправільны ідэнтыфікатар карыстальніка." - }, - { - "id": "model.incoming_hook.user_id.app_error", - "translation": "Няправільны ідэнтыфікатар карыстальніка." - }, - { - "id": "model.channel_member.is_valid.user_id.app_error", - "translation": "Няправільны ідэнтыфікатар карыстальніка." - }, - { - "id": "model.team_member.is_valid.user_id.app_error", "translation": "Недапушчальны ідэнтыфікатар карыстальніка." }, { - "id": "model.authorize.is_valid.response_type.app_error", - "translation": "Памылковы тып адказу." + "id": "model.incoming_hook.user_id.app_error", + "translation": "Няправільны ID карыстальніка." }, { - "id": "model.authorize.is_valid.client_id.app_error", - "translation": "Несапраўдны ідэнтыфікатар кліента." + "id": "model.channel_member.is_valid.user_id.app_error", + "translation": "Несапраўдны ідэнтыфікатар карыстальніка." }, { - "id": "model.authorize.is_valid.auth_code.app_error", - "translation": "Несапраўдны код аўтарызацыі." - }, - { - "id": "model.access.is_valid.user_id.app_error", + "id": "model.team_member.is_valid.user_id.app_error", "translation": "Няправільны ідэнтыфікатар карыстальніка." }, + { + "id": "model.authorize.is_valid.response_type.app_error", + "translation": "Несапраўдны тып адказу." + }, + { + "id": "model.authorize.is_valid.client_id.app_error", + "translation": "Няправільны ідэнтыфікатар кліента." + }, + { + "id": "model.authorize.is_valid.auth_code.app_error", + "translation": "Няправільны код аўтарызацыі." + }, + { + "id": "model.access.is_valid.user_id.app_error", + "translation": "Несапраўдны ідэнтыфікатар карыстальніка." + }, { "id": "model.access.is_valid.refresh_token.app_error", - "translation": "Несапраўдны токен абнаўлення." + "translation": "Няправільны токен абнаўлення." }, { "id": "model.access.is_valid.client_id.app_error", - "translation": "Несапраўдны ідэнтыфікатар кліента." + "translation": "Няправільны ідэнтыфікатар кліента." }, { "id": "model.access.is_valid.access_token.app_error", - "translation": "Несапраўдны токен доступу." + "translation": "Няправільны токен доступу." }, { "id": "mfa.validate_token.authenticate.app_error", - "translation": "Няправiльны MFA токен." + "translation": "Несапраўдны токен MFA." }, { "id": "mfa.activate.bad_token.app_error", - "translation": "Няправiльны MFA токен." + "translation": "Несапраўдны токен MFA." }, { "id": "ent.ldap.validate_filter.app_error", - "translation": "Няправільны фільтар AD/LDAP." + "translation": "Няправільны фільтр AD/LDAP." }, { "id": "ent.ldap.syncronize.get_all_groups.app_error", @@ -1129,15 +1129,15 @@ }, { "id": "ent.elasticsearch.test_config.indexing_disabled.error", - "translation": "Elasticsearch адключаны." + "translation": "{{.Backend}} адключаны." }, { "id": "ent.data_retention.policies.invalid_policy", - "translation": "Няправільная палітыка." + "translation": "Палітыка несапраўдная." }, { "id": "ent.api.post.send_notifications_and_forget.push_image_only", - "translation": " далучыў файл." + "translation": " прымацаваў файл." }, { "id": "app.post.search.app_error", @@ -1145,35 +1145,35 @@ }, { "id": "app.plugin.filesystem.app_error", - "translation": "Адбылася памылка файлавай сістэмы." + "translation": "Памылка файлавай сістэмы." }, { "id": "app.notification.footer.infoLogin", - "translation": "Вайсці у Mattermost" + "translation": "Увайсці ў Mattermost" }, { "id": "app.import.validate_user_import_data.profile_image.error", - "translation": "Няправільны відарыс профілю." + "translation": "Няправільны профільны малюнак." }, { "id": "model.config.is_valid.elastic_search.bulk_indexing_batch_size.app_error", - "translation": "Памер пакета масавага індэксавання Elasticsearch павінен быць не менш за {{.BatchSize}}." + "translation": "Памер партыі масавага індэксавання павінен быць не менш за {{.BatchSize}}." }, { "id": "app.channel.get_file_count.app_error", - "translation": "Не атрымалася атрымаць колькасць файлаў для канала" + "translation": "Немагчыма атрымаць колькасць файлаў для канала" }, { "id": "model.oauth.is_valid.mattermost_app_id.app_error", - "translation": "Максімальная даўжыня MattermostAppID – 32 знака." + "translation": "Максімальная даўжыня MattermostAppID складае 32 сімвалы." }, { "id": "app.prepackged-plugin.invalid_version.app_error", - "translation": "Папярэдне спакаваная версія плагіна не можа быць прааналізавана." + "translation": "Версію папярэдне ўпакаванага плагіна не атрымалася разабраць." }, { "id": "api.team.invite_members.unable_to_send_email_with_defaults.app_error", - "translation": "SMTP не настроены ў Сістэмнай Кансолі" + "translation": "SMTP не наладжаны ў сістэмнай кансолі" }, { "id": "api.team.invite_members.unable_to_send_email.app_error", @@ -1181,11 +1181,11 @@ }, { "id": "app.job.get_all_jobs_by_type_and_status.app_error", - "translation": "Не атрымалася атрымаць усе заданні па тыпе і статуце." + "translation": "Немагчыма атрымаць усе задачы па тыпе і статусе." }, { "id": "app.member_count", - "translation": "памылка пры атрыманні колькасці ўдзельнікаў" + "translation": "памылка атрымання колькасці ўдзельнікаў" }, { "id": "app.group.crud_permission", @@ -1193,27 +1193,27 @@ }, { "id": "app.custom_group.unique_name", - "translation": "імя групы не ўнікальна" + "translation": "Назва групы не ўнікальная" }, { "id": "api.license_error", - "translation": "для канчатковай кропкі API патрабуецца ліцэнзія" + "translation": "API-кропка доступу патрабуе ліцэнзіі" }, { "id": "api.custom_groups.no_remote_id", - "translation": "remote_id павінен быць пустым для карыстацкай групы" + "translation": "remote_id павінна быць пустым для карыстальніцкай групы" }, { "id": "api.custom_groups.must_be_referenceable", - "translation": "allow_reference павінна быць 'true' для карыстацкіх груп" + "translation": "allow_reference павінна быць 'true' для карыстальніцкіх груп" }, { "id": "api.custom_groups.license_error", - "translation": "не ліцэнзавана для карыстацкіх груп" + "translation": "не ліцэнзавана для карыстальніцкіх груп" }, { "id": "api.custom_groups.feature_disabled", - "translation": "функцыя карыстацкіх груп адключана" + "translation": "функцыя карыстальніцкіх груп адключана" }, { "id": "api.custom_groups.count_err", @@ -1221,27 +1221,27 @@ }, { "id": "app.system.get_onboarding_request.app_error", - "translation": "Не ўдалося атрымаць статус завяршэння рэгістрацыі." + "translation": "Немагчыма атрымаць статус завяршэння ўключэння." }, { "id": "api.error_set_first_admin_complete_setup", - "translation": "Памылка пры спробе захаваць першую поўную настройку адміністратара ў краме." + "translation": "Памылка пры спробе захаваць першую завершаную наладу адміністратара ў сховішчы." }, { "id": "api.error_get_first_admin_complete_setup", - "translation": "Памылка пры спробе атрымаць першую завершаную настройку адміністратара з крамы." + "translation": "Памылка пры спробе атрымаць першую завершаную наладу адміністратара са сховішча." }, { "id": "model.emoji.system_emoji_name.app_error", - "translation": "Імя канфліктуе з існуючым сістэмным імем эмодзі." + "translation": "Назва супадае з існуючай назвай сістэмнага эмодзі." }, { "id": "app.system.complete_onboarding_request.no_first_user", - "translation": "Падлучэнне можа быць выканана толькі Сістэмным Адміністратарам." + "translation": "Уключэнне можа быць завершана толькі сістэмным адміністратарам." }, { "id": "app.system.complete_onboarding_request.app_error", - "translation": "Не ўдалося расшыфраваць поўны запыт на падлучэнне." + "translation": "Немагчыма дэкадаваць поўны запыт на ўключэнне." }, { "id": "api.user.authorize_oauth_user.saml_response_too_long.app_error", @@ -1249,19 +1249,19 @@ }, { "id": "api.post.posts_by_ids.invalid_body.request_error", - "translation": "Колькасць атрыманых ID паведамленняў перавышае максімальны памер {{.MaxLength}}" + "translation": "Колькасць атрыманых ідэнтыфікатараў паведамленняў перавысіла максімальны памер {{.MaxLength}}" }, { "id": "app.session.extend_session_expiry.app_error", - "translation": "Немагчыма павялічыць працягласць сеансу" + "translation": "Немагчыма падоўжыць працягласць сесіі" }, { "id": "app.user.get_thread_count_for_user.app_error", - "translation": "Нам не ўдалося атрымаць колькасць абмеркаванняў для карыстальніка." + "translation": "Немагчыма атрымаць колькасць гілак для карыстальніка." }, { "id": "api.custom_status.set_custom_statuses.update.app_error", - "translation": "Не атрымалася абнавіць карыстацкі статус. Калі ласка, дадайце эмоджы ці уласны тэкст ці абодва." + "translation": "Немагчыма абнавіць карыстальніцкі статус. Калі ласка, дадайце эмодзі або карыстальніцкі тэкставы статус, або абодва." }, { "id": "app.role.get_all.app_error", @@ -1269,155 +1269,155 @@ }, { "id": "ent.elasticsearch.getAllTeamMembers.error", - "translation": "Не ўдалося атрымаць спіс усіх удзельнікаў каманды" + "translation": "Немагчыма атрымаць усіх удзельнікаў каманды" }, { "id": "ent.elasticsearch.getAllChannelMembers.error", - "translation": "Не ўдалося атрымаць спіс усіх удзельнікаў канала" + "translation": "Немагчыма атрымаць усіх удзельнікаў канала" }, { "id": "api.user.create_user.bad_token_email_data.app_error", - "translation": "Адрас электроннай пошты ў токене не супадае з адрасам у інфармацыі карыстальніка." + "translation": "Адрас электроннай пошты ў токене не супадае з адрасам у даных карыстальніка." }, { "id": "model.user.is_valid.roles_limit.app_error", - "translation": "Недапушчальныя ролі карыстальнікаў даўжэй {{.Limit}} сімвалаў." + "translation": "Несапраўдныя ролі карыстальніка даўжынёй больш за {{.Limit}} сімвалаў." }, { "id": "model.team_member.is_valid.roles_limit.app_error", - "translation": "Недапушчальныя ролі ўдзельніка каманды даўжэй {{.Limit}} сімвалаў." + "translation": "Няправільныя ролі членаў каманды даўжэйшыя за {{.Limit}} сімвалаў." }, { "id": "model.session.is_valid.user_id.app_error", - "translation": "Недапушчальнае поле UserId для сеанса." + "translation": "Няправільнае поле UserId для сесіі." }, { "id": "model.session.is_valid.roles_limit.app_error", - "translation": "Недапушчальныя ролі сеансу даўжэй {{.Limit}} сімвалаў." + "translation": "Няправільныя ролі сесіі даўжэйшыя за {{.Limit}} сімвалаў." }, { "id": "model.session.is_valid.id.app_error", - "translation": "Недапушчальнае поле ID сеанса." + "translation": "Няправільнае поле Id для сесіі." }, { "id": "model.session.is_valid.create_at.app_error", - "translation": "Недапушчальнае поле CreateAt для сеанса." + "translation": "Няправільнае поле CreateAt для сесіі." }, { "id": "model.channel_member.is_valid.roles_limit.app_error", - "translation": "Недапушчальныя ролі ўдзельнікаў канала даўжэй {{.Limit}} сімвалаў." + "translation": "Несапраўдныя ролі ўдзельнікаў канала даўжэйшыя за {{.Limit}} сімвалаў." }, { "id": "app.notification.body.thread_gm.subTitle", - "translation": "Пакуль Вас не было, {{.SenderName}} адказаў на абмеркаванне ў Вашай групе." + "translation": "Пакуль вас не было, {{.SenderName}} адказаў на гілку абмеркавання ў вашай групе." }, { "id": "app.notification.body.thread_dm.subTitle", - "translation": "Пакуль Вас не было, {{.SenderName}} адказаў на абмеркаванне ў Вашым асабістым паведамленні." + "translation": "Пакуль вас не было, {{.SenderName}} адказаў на гілку абмеркавання ў вашым прамым паведамленні." }, { "id": "app.notification.body.thread_channel_full.subTitle", - "translation": "Пакуль Вас не было, {{.SenderName}} адказаў у абмеркаванні, за якім Вы сачыце на {{.ChannelName}}." + "translation": "Пакуль вас не было, {{.SenderName}} адказаў на гілку абмеркавання, за якой вы сочыце ў {{.ChannelName}}." }, { "id": "app.notification.body.thread_channel.subTitle", - "translation": "Пакуль Вас не было, {{.SenderName}} адказаў у абмеркаванні, за якім Вы сачыце." + "translation": "Пакуль вас не было, {{.SenderName}} адказаў на гілку абмеркавання, за якой вы сочыце." }, { "id": "api.push_notification.title.collapsed_threads_dm", - "translation": "Адказаць у асабістым паведамленні" + "translation": "Адказаць у прамым паведамленні" }, { "id": "api.post.send_notification_and_forget.push_comment_on_crt_thread_dm", - "translation": " адказаў у абмеркаванні." + "translation": " адказаў у гілцы." }, { "id": "app.post.marshal.app_error", - "translation": "Не ўдалося ўпарадкаваць паведамленне." + "translation": "Не атрымалася апрацаваць паведамленне." }, { "id": "api.unmarshal_error", - "translation": "Не ўдалося ўпарадкаваць." + "translation": "Не атрымалася дэсерыялізаваць." }, { "id": "web.incoming_webhook.user.app_error", - "translation": "Не ўдалося знайсці карыстальніка." + "translation": "Не атрымалася знайсці карыстальніка {{.user}}" }, { "id": "web.incoming_webhook.text.app_error", - "translation": "Тэкст не зададзены." + "translation": "Тэкст не пазначаны." }, { "id": "web.incoming_webhook.split_props_length.app_error", - "translation": "Немагчыма падзяліць рэквізіты вэбхука на часткі сімвалаў па {{.Max}}." + "translation": "Немагчыма падзяліць уласцівасці вэбхука на часткі па {{.Max}} сімвалаў." }, { "id": "web.incoming_webhook.permissions.app_error", - "translation": "Неадпаведныя правы канала." + "translation": "Карыстальнік {{.user}} не мае адпаведных правоў на канал {{.channel}}" }, { "id": "web.incoming_webhook.parse.app_error", - "translation": "Немагчыма разабраць уваходныя дадзеныя." + "translation": "Немагчыма прааналізаваць уваходныя даныя." }, { "id": "web.incoming_webhook.disabled.app_error", - "translation": "Уваходныя вэбхукі адключаныя сістэмным адміністратарам." + "translation": "Уваходныя вэбхукі адключаны сістэмным адміністратарам." }, { "id": "web.incoming_webhook.channel_locked.app_error", - "translation": "Гэтаму вэбхуку не дазволена публікаваць паведамленні на запытаным канале." + "translation": "Гэты вэбхук не мае дазволу публікаваць у канале {{.channel_id}}" }, { "id": "web.incoming_webhook.channel.app_error", - "translation": "Не ўдалося знайсці канал." + "translation": "Немагчыма знайсці канал." }, { "id": "web.get_access_token.internal_saving.app_error", - "translation": "Немагчыма абнавіць дадзеныя доступу карыстальніка." + "translation": "Немагчыма абнавіць даныя доступу карыстальніка." }, { "id": "web.error.unsupported_browser.no_longer_support_version", - "translation": "Гэтая вэрсія вашага браўзэра больш не падтрымліваецца Mattermost" + "translation": "Гэтая версія вашага браўзера больш не падтрымліваецца Mattermost" }, { "id": "web.error.unsupported_browser.no_longer_support", - "translation": "Гэты браўзэр больш не падтрымліваецца Mattermost" + "translation": "Гэты браўзер больш не падтрымліваецца Mattermost" }, { "id": "web.error.unsupported_browser.learn_more", - "translation": "Падрабязнасці аб падтрымліваемых браўзэрах." + "translation": "Даведайцеся больш пра падтрымоўваныя браўзеры." }, { "id": "web.error.unsupported_browser.download_the_app", - "translation": "Спампаваць дадатак" + "translation": "Спампаваць праграму" }, { "id": "web.error.unsupported_browser.download_app_or_upgrade_browser", - "translation": "Загрузіце дадатак Mattermost або выкарыстоўвайце падтрымліваемы браўзэр для лепшай працы." + "translation": "Спампуйце праграму Mattermost або выкарыстоўвайце падтрымоўваны браўзер для лепшага досведу." }, { "id": "web.error.unsupported_browser.download", - "translation": "Спампаваць дадатак" + "translation": "Спампаваць праграму" }, { "id": "web.error.unsupported_browser.browser_get_latest.safari", - "translation": "Усталюйце апошнюю версію браўзэра Safari" + "translation": "Атрымаць апошнюю версію браўзера Safari" }, { "id": "web.error.unsupported_browser.browser_get_latest.firefox", - "translation": "Усталюйце апошнюю версію браўзэра Firefox" + "translation": "Атрымаць апошнюю версію браўзера Firefox" }, { "id": "web.error.unsupported_browser.browser_get_latest.chrome", - "translation": "Усталюйце апошнюю версію браўзэра Chrome" + "translation": "Атрымаць апошнюю версію браўзера Chrome" }, { "id": "web.command_webhook.parse.app_error", - "translation": "Немагчыма разабраць уваходныя дадзеныя." + "translation": "Немагчыма прааналізаваць уваходныя даныя для вэбхука {{.hook_id}}." }, { "id": "web.command_webhook.command.app_error", - "translation": "Не ўдалося знайсці каманду." + "translation": "Немагчыма знайсці каманду {{.command_id}}." }, { "id": "store.sql_user.get_for_login.app_error", @@ -1425,19 +1425,19 @@ }, { "id": "store.sql_team.save_member.exists.app_error", - "translation": "Удзельнік каманды з такім ідэнтыфікатарам ужо існуе." + "translation": "Удзельнік каманды з такім ID ужо існуе." }, { "id": "store.sql_post.search.disabled", - "translation": "Пошук на гэтым сэрвэры адключаны. Калі ласка, звярніцеся да адміністратара." + "translation": "Пошук адключаны на гэтым серверы. Звярніцеся да вашага сістэмнага адміністратара." }, { "id": "store.sql_file_info.search.disabled", - "translation": "Пошук файлаў на гэтым сэрвэры адключаны. Калі ласка, звярніцеся да адміністратара." + "translation": "Пошук файлаў адключаны на гэтым серверы. Звярніцеся да вашага сістэмнага адміністратара." }, { "id": "store.sql_command.update.missing.app_error", - "translation": "Каманда не існуе." + "translation": "Каманды не існуе." }, { "id": "store.sql_command.save.get.app_error", @@ -1445,271 +1445,271 @@ }, { "id": "store.sql_command.get.missing.app_error", - "translation": "Каманда не існуе." + "translation": "Каманды не існуе." }, { "id": "store.sql_channel.save_direct_channel.not_direct.app_error", - "translation": "Спроба стварыць канал, які не з'яўляецца асабістым з выкарыстаннем SaveDirectChannel." + "translation": "Спроба стварыць прамы канал з дапамогай SaveDirectChannel не ўдалася." }, { "id": "store.sql_channel.save_channel.limit.app_error", - "translation": "Вы дасягнулі мяжы колькасці дазволеных каналаў." + "translation": "Вы дасягнулі ліміту дазволеных каналаў." }, { "id": "store.sql_channel.save_channel.exists.app_error", - "translation": "Канал з такім імем ужо існуе ў гэтай камандзе." + "translation": "Канал з такой назвай ужо існуе ў гэтай камандзе." }, { "id": "store.sql_channel.save_channel.existing.app_error", - "translation": "Неабходна выклікаць абнаўленне для існуючага канала." + "translation": "Трэба выклікаць абнаўленне для існуючага канала." }, { "id": "store.sql_channel.save.direct_channel.app_error", - "translation": "Выкарыстоўвайце SaveDirectChannel для стварэння канала асабістых паведамленняў." + "translation": "Выкарыстоўвайце SaveDirectChannel для стварэння прамога канала." }, { "id": "store.sql_channel.save.archived_channel.app_error", - "translation": "Вы не можаце рэдагаваць архіўны канал." + "translation": "Вы не можаце змяняць архіваваны канал." }, { "id": "store.sql_channel.get.existing.app_error", - "translation": "Не ўдалося знайсці існуючы канал." + "translation": "Немагчыма знайсці існуючы канал." }, { "id": "store.sql_bot.get.missing.app_error", - "translation": "Бот не існуе." + "translation": "Бота не існуе." }, { "id": "sharedchannel.permalink.not_found", - "translation": "Гэты пост змяшчае спасылкі на іншыя каналы, якія могуць быць нябачныя для карыстальнікаў іншых сайтаў." + "translation": "Гэта паведамленне змяшчае пастаянныя спасылкі на іншыя каналы, якія могуць быць не бачныя карыстальнікам на іншых сайтах." }, { "id": "sharedchannel.cannot_deliver_post", - "translation": "Адно ці некалькі паведамленняў не могуць быць дастаўлены на выдалены сайт {{.Remote}}, бо ён у афлайне. Паведамленні будуць дастаўлены, калі сайт будзе анлайн." + "translation": "Адзін або некалькі паведамленняў не могуць быць дастаўлены на аддалены сайт {{.Remote}}, бо ён па-за сеткай. Паведамленні будуць дастаўлены, калі сайт будзе ў сетцы." }, { "id": "plugin_api.send_mail.missing_to", - "translation": "Адсутнічае адрас \"Каму\"." + "translation": "Адсутнічае адрас атрымальніка." }, { "id": "plugin_api.send_mail.missing_subject", - "translation": "Адсутнічае тэма email-ліста." + "translation": "Адсутнічае тэма ліста." }, { "id": "plugin_api.send_mail.missing_htmlbody", - "translation": "Адсутнічае HTML Body." + "translation": "Адсутнічае цела HTML." }, { "id": "plugin_api.get_file_link.no_post.app_error", - "translation": "Не ўдалося атрымаць публічную спасылку для файла. Файл павінен быць прымацаваны да паведамлення, якое можа быць прачытана бягучым карыстальнікам." + "translation": "Немагчыма атрымаць публічную спасылку на файл. Файл павінен быць прымацаваны да паведамлення, якое можна прачытаць." }, { "id": "plugin_api.get_file_link.disabled.app_error", - "translation": "Публічныя спасылкі адключаныя." + "translation": "Публічныя спасылкі адключаны." }, { "id": "plugin_api.bot_cant_create_bot", - "translation": "Уліковы запіс робата не можа стварыць іншы ўліковы запіс робата." + "translation": "Бот не можа стварыць іншага бота." }, { "id": "plugin.api.update_user_status.bad_status", - "translation": "Немагчыма ўсталяваць статут карыстача. Невядомы статус карыстальніка." + "translation": "Немагчыма ўсталяваць статус карыстальніка. Невядомы статус карыстальніка." }, { "id": "plugin.api.get_users_in_channel", - "translation": "Не ўдалося атрымаць спіс карыстальнікаў, некарэктны крытэрый сартавання." + "translation": "Немагчыма атрымаць спіс карыстальнікаў, несапраўдныя крытэрыі сартавання." }, { "id": "oauth.gitlab.tos.error", - "translation": "Умовы прадастаўлення паслуг GitLab абноўлены. Калі ласка, зайдзіце на gitlab.com, каб прыняць іх, а затым паспрабуйце зноў увайсці ў Mattermost." + "translation": "Умовы выкарыстання GitLab абноўлены. Калі ласка, перайдзіце па спасылцы {{.URL}}, каб прыняць іх, а затым паспрабуйце зноў увайсці ў Mattermost." }, { "id": "model.user_access_token.is_valid.token.app_error", - "translation": "Несапраўдны токен доступу." + "translation": "Няправільны токен доступу." }, { "id": "model.user_access_token.is_valid.id.app_error", - "translation": "Недапушчальнае значэнне для ідэнтыфікатара." + "translation": "Несапраўднае значэнне для id." }, { "id": "model.user_access_token.is_valid.description.app_error", - "translation": "Няправільнае апісанне, павінна быць не больш за 255 сімвалаў." + "translation": "Несапраўднае апісанне, павінна быць 255 або менш сімвалаў." }, { "id": "model.user.is_valid.username.app_error", - "translation": "Username павінен быць з ліста і спіс паміж 3 і 22 characters including numbers, lowercase letters, and the symbols \".\", \"-\", and \"_\"." + "translation": "Імя карыстальніка павінна пачынацца з літары і змяшчаць ад 3 да 22 малых літар, лічбаў і сімвалаў \".\", \"-\" і \"_\"." }, { "id": "model.user.is_valid.update_at.app_error", - "translation": "\"Абноўлена ў\" павінна быць карэктным часам." + "translation": "Час абнаўлення павінен быць сапраўдным." }, { "id": "model.user.is_valid.pwd_uppercase_symbol.app_error", - "translation": "Ваш пароль павінен змяшчаць як мінімум {{.Min}} сімвалаў, уключаючы прынамсі адну загалоўную літару і адзін спецзнак (напр., \"~!@#$%^&*()\")." + "translation": "Ваш пароль павінен змяшчаць не менш за {{.Min}} сімвалаў, у тым ліку прынамсі адну вялікую літару і адзін сімвал (напрыклад, \"~!@#$%^&*()\")." }, { "id": "model.user.is_valid.pwd_uppercase_number_symbol.app_error", - "translation": "Ваш пароль павінен змяшчаць як мінімум {{.Min}} сімвалаў, уключаючы прынамсі адну загалоўную літару, адну лічбу і адзін спецзнак (напр., \"~!@#$%^&*()\")." + "translation": "Ваш пароль павінен змяшчаць не менш за {{.Min}} сімвалаў, у тым ліку прынамсі адну вялікую літару, адну лічбу і адзін сімвал (напрыклад, \"~!@#$%^&*()\")." }, { "id": "model.user.is_valid.pwd_uppercase_number.app_error", - "translation": "Ваш пароль павінен утрымоўваць прынамсі {{.Min}} знакаў, уключаючы прынамсі адну загалоўную літару і адну лічбу." + "translation": "Ваш пароль павінен змяшчаць не менш за {{.Min}} сімвалаў, у тым ліку прынамсі адну вялікую літару і адну лічбу." }, { "id": "model.user.is_valid.pwd_uppercase.app_error", - "translation": "Ваш пароль павінен утрымоўваць прынамсі {{.Min}} знакаў, уключаючы прынамсі адну загалоўную літару." + "translation": "Ваш пароль павінен змяшчаць не менш за {{.Min}} сімвалаў, у тым ліку прынамсі адну вялікую літару." }, { "id": "model.user.is_valid.pwd_symbol.app_error", - "translation": "Ваш пароль павінен змяшчаць як мінімум {{.Min}} сімвалаў, у тым ліку па меншай меры адзін спецзнак (напр., \"~!@#$%^&*()\")." + "translation": "Ваш пароль павінен змяшчаць не менш за {{.Min}} сімвалаў, у тым ліку прынамсі адзін сімвал (напрыклад, \"~!@#$%^&*()\")." }, { "id": "model.user.is_valid.pwd_number_symbol.app_error", - "translation": "Ваш пароль павінен змяшчаць як мінімум {{.Min}} сімвалаў, уключаючы па меншай меры адну лічбу і адзін спецзнак (напр., \"~!@#$%^&*()\")." + "translation": "Ваш пароль павінен змяшчаць не менш за {{.Min}} сімвалаў, у тым ліку прынамсі адну лічбу і адзін сімвал (напрыклад, \"~!@#$%^&*()\")." }, { "id": "model.user.is_valid.pwd_number.app_error", - "translation": "Ваш пароль павінен змяшчаць як мінімум {{.Min}} сімвалаў, прынамсі адзін з якіх павінен быць лічбай." + "translation": "Ваш пароль павінен змяшчаць не менш за {{.Min}} сімвалаў, у тым ліку прынамсі адну лічбу." }, { "id": "model.user.is_valid.pwd_lowercase_uppercase_symbol.app_error", - "translation": "Ваш пароль павінен утрымоўваць прынамсі {{.Min}} знакаў, а гэтак жа адну літару ў ніжнім рэгістры, адну літару ў верхнім рэгістры, адну лічбу і спец.знак (\"~!@#$%^&*()\" )." + "translation": "Ваш пароль павінен змяшчаць не менш за {{.Min}} сімвалаў, у тым ліку прынамсі адну малую літару, адну вялікую літару і адзін сімвал (напрыклад, \"~!@#$%^&*()\")." }, { "id": "model.user.is_valid.pwd_lowercase_uppercase_number_symbol.app_error", - "translation": "Ваш пароль павінен утрымоўваць прынамсі {{.Min}} знакаў, а гэтак жа адну літару ў ніжнім рэгістры, адну літару верхнім рэгістры, адну лічбу і спец.знак (\"~!@#$%^&*()\") ." + "translation": "Ваш пароль павінен змяшчаць не менш за {{.Min}} сімвалаў, у тым ліку прынамсі адну малую літару, адну вялікую літару, адну лічбу і адзін сімвал (напрыклад, \"~!@#$%^&*()\")." }, { "id": "model.user.is_valid.pwd_lowercase_uppercase_number.app_error", - "translation": "Ваш пароль павінен утрымоўваць прынамсі {{.Min}} знакаў, а гэтак жа адну літару ў верхнім рэгістры і адну лічбу." + "translation": "Ваш пароль павінен змяшчаць не менш за {{.Min}} сімвалаў, у тым ліку прынамсі адну малую літару, адну вялікую літару і адну лічбу." }, { "id": "model.user.is_valid.pwd_lowercase_uppercase.app_error", - "translation": "Ваш пароль павінен утрымоўваць прынамсі {{.Min}} знакаў, а гэтак жа адну літару ў ніжнім і верхнім рэгістры." + "translation": "Ваш пароль павінен змяшчаць не менш за {{.Min}} сімвалаў, у тым ліку прынамсі адну малую літару і адну вялікую літару." }, { "id": "model.user.is_valid.pwd_lowercase_symbol.app_error", - "translation": "Ваш пароль павінен утрымоўваць прынамсі {{.Min}} знакаў, а гэтак жа адну літару ў ніжнім рэгістры і адзін спец.знак (\"~!@#$%^&*()\")." + "translation": "Ваш пароль павінен змяшчаць не менш за {{.Min}} сімвалаў, у тым ліку прынамсі адну малую літару і адзін сімвал (напрыклад, \"~!@#$%^&*()\")." }, { "id": "model.user.is_valid.pwd_lowercase_number_symbol.app_error", - "translation": "Ваш пароль павінен утрымоўваць прынамсі {{.Min}} знакаў, а гэтак жа адну літару ў ніжнім рэгістры, адну лічбу і адзін спец.знак (\"~!@#$%^&*()\")." + "translation": "Ваш пароль павінен змяшчаць не менш за {{.Min}} сімвалаў, у тым ліку прынамсі адну малую літару, адну лічбу і адзін сімвал (напрыклад, \"~!@#$%^&*()\")." }, { "id": "model.user.is_valid.pwd_lowercase_number.app_error", - "translation": "Ваш пароль павінен утрымоўваць прынамсі {{.Min}} знакаў, а гэтак жа адну літару ў ніжнім рэгістры і адну лічбу." + "translation": "Ваш пароль павінен змяшчаць не менш за {{.Min}} сімвалаў, у тым ліку прынамсі адну малую літару і адну лічбу." }, { "id": "model.user.is_valid.pwd_lowercase.app_error", - "translation": "Ваш пароль павінен утрымоўваць прынамсі {{.Min}} знакаў, а гэтак жа адну літару ў ніжнім рэгістры." + "translation": "Ваш пароль павінен змяшчаць не менш за {{.Min}} сімвалаў, у тым ліку прынамсі адну малую літару." }, { "id": "model.user.is_valid.position.app_error", - "translation": "Няслушная пазіцыя: павінна быць не больш за 128 сімвалаў." + "translation": "Несапраўдная пасада: не павінна быць даўжэйшая за 128 сімвалаў." }, { "id": "model.user.is_valid.password_limit.app_error", - "translation": "Немагчыма ўсталяваць пароль даўжынёй больш за 72 сімвалаў у сувязі з абмежаваннямі bcrypt." + "translation": "Немагчыма ўсталяваць пароль даўжынёй больш за 72 сімвалы з-за абмежаванняў bcrypt." }, { "id": "model.user.is_valid.marshal.app_error", - "translation": "Не атрымалася закадаваць поле ў JSON" + "translation": "Немагчыма зашыфраваць поле ў JSON" }, { "id": "model.user.is_valid.first_name.app_error", - "translation": "Недапушчальнае імя." + "translation": "Несапраўднае імя." }, { "id": "model.user.is_valid.create_at.app_error", - "translation": "\"Створана ў\" павінна быць карэктным часам." + "translation": "Час стварэння павінен быць сапраўдным." }, { "id": "model.user.is_valid.auth_data_type.app_error", - "translation": "Некарэктны карыстальнік, дадзеныя аўтарызацыі павінны быць устаноўлены ў тыпе аўтарызацыі." + "translation": "Несапраўдны карыстальнік: даныя аўтэнтыфікацыі можна ўсталяваць толькі ў тым выпадку, калі карыстальнік выкарыстоўвае для аўтэнтыфікацыі знешнюю службу аўтэнтыфікацыі, а не электронную пошту/пароль." }, { "id": "model.user.is_valid.auth_data_pwd.app_error", - "translation": "Некарэктны карыстач, пароль і дадзеныя аўтарызацыі не могуць быць усталяваныя адначасова." + "translation": "Несапраўдныя даныя карыстальніка, пароля і аўтэнтыфікацыі не могуць быць устаноўлены адначасова." }, { "id": "model.user.is_valid.auth_data.app_error", - "translation": "Недапушчальныя дадзеныя аўтарызацыі." + "translation": "Несапраўдныя даныя аўтэнтыфікацыі." }, { "id": "model.upload_session.is_valid.user_id.app_error", - "translation": "Недапушчальнае значэнне для UserId" + "translation": "Несапраўднае значэнне для UserId" }, { "id": "model.upload_session.is_valid.type.app_error", - "translation": "Недапушчальнае значэнне для Type" + "translation": "Несапраўднае значэнне для Type" }, { "id": "model.upload_session.is_valid.path.app_error", - "translation": "Недапушчальнае значэнне для Path" + "translation": "Няправільнае значэнне для Path" }, { "id": "model.upload_session.is_valid.id.app_error", - "translation": "Недапушчальнае значэнне для Id" + "translation": "Няправільнае значэнне для Id" }, { "id": "model.upload_session.is_valid.filename.app_error", - "translation": "Некарэктнае значэнне для Filename" + "translation": "Няправільнае значэнне для Filename" }, { "id": "model.upload_session.is_valid.file_size.app_error", - "translation": "Некарэктнае значэнне для FileSize" + "translation": "Няправільнае значэнне для FileSize" }, { "id": "model.upload_session.is_valid.file_offset.app_error", - "translation": "Некарэктнае значэнне для FileOffset" + "translation": "Няправільнае значэнне для FileOffset" }, { "id": "model.upload_session.is_valid.create_at.app_error", - "translation": "Недапушчальнае значэнне для CreateAt" + "translation": "Няправільнае значэнне для CreateAt" }, { "id": "model.upload_session.is_valid.channel_id.app_error", - "translation": "Некарэктнае значэнне для ChannelId." + "translation": "Няправільнае значэнне для ChannelId." }, { "id": "model.token.is_valid.expiry", - "translation": "Недапушчальны тэрмін дзеяння токена" + "translation": "Няправільны тэрмін дзеяння токена" }, { "id": "model.team_member.is_valid.team_id.app_error", - "translation": "Недапушчальны ідэнтыфікатар каманды." + "translation": "Няправільны ідэнтыфікатар каманды." }, { "id": "model.team.is_valid.url.app_error", - "translation": "Недапушчальны ідэнтыфікатар URL." + "translation": "Няправільны ідэнтыфікатар URL." }, { "id": "model.team.is_valid.update_at.app_error", - "translation": "\"Абноўлена ў\" павінна быць карэктным часам." + "translation": "Час абнаўлення павінен быць сапраўдным." }, { "id": "model.team.is_valid.reserved.app_error", - "translation": "Гэты URL-адрас недаступны. Калі ласка, увядзіце іншы." + "translation": "Гэты URL недаступны. Калі ласка, паспрабуйце іншы." }, { "id": "model.team.is_valid.invite_id.app_error", - "translation": "Недапушчальны ідэнтыфікатар запрашэння." + "translation": "Няправільны ідэнтыфікатар запрашэння." }, { "id": "model.team.is_valid.domains.app_error", - "translation": "Недапушчальныя дазволеныя дамены." + "translation": "Няправільныя дазволеныя дамены." }, { "id": "model.team.is_valid.create_at.app_error", - "translation": "\"Створана ў\" павінна быць карэктным часам." + "translation": "Поле \"Створана\" павінна ўтрымліваць правільны час." }, { "id": "model.team.is_valid.company.app_error", - "translation": "Недапушчальнае імя кампаніі." + "translation": "Няправільная назва кампаніі." }, { "id": "model.team.is_valid.characters.app_error", - "translation": "Імя павінна складацца з 2 ці больш малых літар і лічбаў." + "translation": "Імя павінна складацца з 2 або больш малымі літарамі і лічбамі." }, { "id": "model.search_params_list.is_valid.include_deleted_channels.app_error", @@ -1717,575 +1717,575 @@ }, { "id": "model.reaction.is_valid.update_at.app_error", - "translation": "\"Абнавіць у\" павінна быць карэктным часам." + "translation": "Час абнаўлення павінен быць сапраўдным." }, { "id": "model.reaction.is_valid.post_id.app_error", - "translation": "Недапушчальны ідэнтыфікатар паведамлення." + "translation": "Няправільны ідэнтыфікатар паведамлення." }, { "id": "model.reaction.is_valid.emoji_name.app_error", - "translation": "Недапушчальная назва эмоджы." + "translation": "Няправільная назва эмодзі." }, { "id": "model.reaction.is_valid.create_at.app_error", - "translation": "\"Створана ў\" павінна быць карэктным часам." + "translation": "Поле \"Створана\" павінна ўтрымліваць правільны час." }, { "id": "model.post.is_valid.update_at.app_error", - "translation": "\"Абноўлена ў\" павінна быць карэктным часам." + "translation": "Час абнаўлення павінен быць сапраўдным." }, { "id": "model.post.is_valid.root_id.app_error", - "translation": "Недапушчальны каранёвы ідэнтыфікатар." + "translation": "Няправільны каранёвы ідэнтыфікатар." }, { "id": "model.post.is_valid.original_id.app_error", - "translation": "Недапушчальны ідэнтыфікатар арыгінала." + "translation": "Няправільны зыходны ідэнтыфікатар." }, { "id": "model.post.is_valid.file_ids.app_error", - "translation": "Няправільныя ID файлаў. Звярніце ўвагу, што загрузка абмежавана максімум 10 файламі. Калі ласка, выкарыстайце дадатковыя паведамленні для адпраўкі большай колькасці файлаў." + "translation": "Няправільныя ідэнтыфікатары файлаў. Звярніце ўвагу, што загрузка абмежавана максімум 10 файламі. Калі ласка, выкарыстоўвайце дадатковыя паведамленні для большай колькасці файлаў." }, { "id": "model.post.is_valid.create_at.app_error", - "translation": "\"Створана ў\" павінна быць карэктным часам." + "translation": "Поле \"Створана\" павінна ўтрымліваць правільны фармат часу." }, { "id": "model.post.is_valid.channel_id.app_error", - "translation": "Недапушчальны ідэнтыфікатар канала." + "translation": "Няправільны ідэнтыфікатар канала." }, { "id": "model.post.channel_notifications_disabled_in_channel.message", - "translation": "Апавяшчэнні канала адключаныя ў {{.ChannelName}}. {{.Mention}} не выклікае ніякіх апавяшчэнняў." + "translation": "Апавяшчэнні канала адключаны ў {{.ChannelName}}. {{.Mention}} не выклікаў ніякіх апавяшчэнняў." }, { "id": "model.plugin_kvset_options.is_valid.old_value.app_error", - "translation": "Некарэктнае старое значэнне, яно не павінна было быць устаноўлена, калі аперацыя не з'яўляецца атамарнай." + "translation": "Няправільнае старое значэнне, яно не павінна было быць устаноўлена, калі аперацыя не з'яўляецца атамнай." }, { "id": "model.plugin_key_value.is_valid.plugin_id.app_error", - "translation": "Няверны ідэнтыфікатар плагіна, павінен быць даўжэй {{.Min}} і мець даўжыню не больш за {{.Max}} знакаў." + "translation": "Няправільны ідэнтыфікатар плагіна, павінен быць даўжэйшы за {{.Min}} і не больш за {{.Max}} сімвалаў." }, { "id": "model.plugin_key_value.is_valid.key.app_error", - "translation": "Няправільны ключ, павінен быць даўжэй {{.Min}} і ўтрымоўваць не больш за {{.Max}} знакаў." + "translation": "Няправільны ключ, павінен быць даўжэйшы за {{.Min}} і не больш за {{.Max}} сімвалаў." }, { "id": "model.plugin_command_error.error.app_error", - "translation": "Убудова для /{{.Command}} не працуе. Калі ласка, звяжыцеся з Вашым сістэмным адміністратарам" + "translation": "Плагін для /{{.Command}} не працуе. Калі ласка, звярніцеся да вашага сістэмнага адміністратара" }, { "id": "model.plugin_command_crash.error.app_error", - "translation": "/{{.Command}} каманда парушыла працу плагіна {{.PluginId}}. Калі ласка, звяжыцеся з Вашым сістэмным адміністратарам" + "translation": "Каманда /{{.Command}} выклікала збой плагіна {{.PluginId}}. Калі ласка, звярніцеся да вашага сістэмнага адміністратара" }, { "id": "model.plugin_command.error.app_error", - "translation": "Адбылася памылка пры спробе выканаць гэтую каманду." + "translation": "Адбылася памылка пры спробе выканаць гэту каманду." }, { "id": "model.outgoing_hook.is_valid.words.app_error", - "translation": "Некарэктныя словы-трыгеры." + "translation": "Няправільныя трыгерныя словы." }, { "id": "model.outgoing_hook.is_valid.url.app_error", - "translation": "Некарэктны URL зваротнага выкліку. Адрас URL павінен быць правільным і пачацца з http:// ці https://." + "translation": "Няправільныя URL зваротных выклікаў. Кожны з іх павінен быць сапраўдным URL і пачынацца з http:// або https://." }, { "id": "model.outgoing_hook.is_valid.update_at.app_error", - "translation": "\"Абноўлена ў\" павінна быць карэктным часам." + "translation": "Час абнаўлення павінен быць сапраўдным." }, { "id": "model.outgoing_hook.is_valid.trigger_words.app_error", - "translation": "Некарэктныя словы-трыгеры." + "translation": "Няправільныя трыгерныя словы." }, { "id": "model.outgoing_hook.is_valid.team_id.app_error", - "translation": "Недапушчальны ідэнтыфікатар каманды." - }, - { - "id": "model.outgoing_hook.is_valid.create_at.app_error", - "translation": "\"Створана ў\" павінна быць карэктным часам." - }, - { - "id": "model.outgoing_hook.is_valid.content_type.app_error", - "translation": "Недапушчальнае значэнне для content_type." - }, - { - "id": "model.outgoing_hook.is_valid.channel_id.app_error", - "translation": "Няправiльны ідэнтыфікатар канала." - }, - { - "id": "model.outgoing_hook.is_valid.callback.app_error", - "translation": "Няправільныя URL-адрасы зваротнага выкліку." - }, - { - "id": "model.oauth.is_valid.update_at.app_error", - "translation": "\"Абноўлена ў\" павінна быць карэктным часам." - }, - { - "id": "model.oauth.is_valid.icon_url.app_error", - "translation": "Адрас абразка павінен быць карэктным URL-адрасам і пачынацца з http:// або https://." - }, - { - "id": "model.oauth.is_valid.homepage.app_error", - "translation": "Адрас хатняй старонкі павінен быць карэктным URL-адрасам і пачынацца з http:// або https://." - }, - { - "id": "model.oauth.is_valid.creator_id.app_error", - "translation": "Недапушчальны ідэнтыфікатар стваральніка." - }, - { - "id": "model.oauth.is_valid.create_at.app_error", - "translation": "\"Створана ў\" павінна быць карэктным часам." - }, - { - "id": "model.oauth.is_valid.client_secret.app_error", - "translation": "Некарэктны сакрэт кліента." - }, - { - "id": "model.oauth.is_valid.callback.app_error", - "translation": "Callback URL павінен быць карэктным URL-адрасам і пачынацца з http:// або https://." - }, - { - "id": "model.oauth.is_valid.app_id.app_error", - "translation": "Некарэктны ідэнтыфікатар прыкладання." - }, - { - "id": "model.link_metadata.is_valid.url.app_error", - "translation": "URL метададзеных спасылкі павінен быць зададзены." - }, - { - "id": "model.link_metadata.is_valid.type.app_error", - "translation": "Няправільны тып метададзеных спасылкі." - }, - { - "id": "model.link_metadata.is_valid.timestamp.app_error", - "translation": "Пазнака часу метададзеных спасылкі павінна быць ненулявой і акругленая да бліжэйшай гадзіны." - }, - { - "id": "model.link_metadata.is_valid.data_type.app_error", - "translation": "Дадзеныя метададзеных спасылкі не адпавядаюць зададзенаму тыпу." - }, - { - "id": "model.link_metadata.is_valid.data.app_error", - "translation": "Дадзеныя метададзеных спасылкі не могуць быць нулявымі." - }, - { - "id": "model.license_record.is_valid.id.app_error", - "translation": "Няправільнае значэнне для id пры загрузцы файла ліцэнзіі." - }, - { - "id": "model.license_record.is_valid.create_at.app_error", - "translation": "Няправільнае значэнне для create_at пры загрузцы файла ліцэнзіі." - }, - { - "id": "model.job.is_valid.type.app_error", - "translation": "Некарэктны тып задання." - }, - { - "id": "model.job.is_valid.status.app_error", - "translation": "Некарэктны статус задання." - }, - { - "id": "model.job.is_valid.id.app_error", - "translation": "Некарэктны ідэнтыфікатар задання." - }, - { - "id": "model.job.is_valid.create_at.app_error", - "translation": "\"Створана ў\" павінна быць карэктным часам." - }, - { - "id": "model.incoming_hook.update_at.app_error", - "translation": "\"Абноўлена ў\" павінна быць карэктным часам." - }, - { - "id": "model.incoming_hook.team_id.app_error", "translation": "Няправільны ідэнтыфікатар каманды." }, + { + "id": "model.outgoing_hook.is_valid.create_at.app_error", + "translation": "Поле \"Створана\" павінна ўтрымліваць правільны час." + }, + { + "id": "model.outgoing_hook.is_valid.content_type.app_error", + "translation": "Няправільнае значэнне для content_type." + }, + { + "id": "model.outgoing_hook.is_valid.channel_id.app_error", + "translation": "Няправільны ідэнтыфікатар канала." + }, + { + "id": "model.outgoing_hook.is_valid.callback.app_error", + "translation": "Няправільныя URL зваротных выклікаў." + }, + { + "id": "model.oauth.is_valid.update_at.app_error", + "translation": "Час абнаўлення павінен быць сапраўдным." + }, + { + "id": "model.oauth.is_valid.icon_url.app_error", + "translation": "URL значка павінен быць сапраўдным URL і пачынацца з http:// або https://." + }, + { + "id": "model.oauth.is_valid.homepage.app_error", + "translation": "Хатняя старонка павінна быць сапраўдным URL і пачынацца з http:// або https://." + }, + { + "id": "model.oauth.is_valid.creator_id.app_error", + "translation": "Няправільны ідэнтыфікатар стваральніка." + }, + { + "id": "model.oauth.is_valid.create_at.app_error", + "translation": "Поле \"Створана\" павінна ўтрымліваць правільны час." + }, + { + "id": "model.oauth.is_valid.client_secret.app_error", + "translation": "Няправільны сакрэт кліента." + }, + { + "id": "model.oauth.is_valid.callback.app_error", + "translation": "URL зваротнага выкліку павінен быць сапраўдным URL і пачынацца з http:// або https://." + }, + { + "id": "model.oauth.is_valid.app_id.app_error", + "translation": "Няправільны ідэнтыфікатар праграмы." + }, + { + "id": "model.link_metadata.is_valid.url.app_error", + "translation": "URL метаданых спасылкі павінен быць усталяваны." + }, + { + "id": "model.link_metadata.is_valid.type.app_error", + "translation": "Няправільны тып метаданых спасылкі." + }, + { + "id": "model.link_metadata.is_valid.timestamp.app_error", + "translation": "Час метаданых спасылкі павінен быць ненулявым і акругленым да найбліжэйшай гадзіны." + }, + { + "id": "model.link_metadata.is_valid.data_type.app_error", + "translation": "Даныя метаданых спасылкі не адпавядаюць зададзенаму тыпу." + }, + { + "id": "model.link_metadata.is_valid.data.app_error", + "translation": "Даныя метаданых спасылкі не могуць быць null." + }, + { + "id": "model.license_record.is_valid.id.app_error", + "translation": "Няправільнае значэнне для id пры загрузцы ліцэнзіі." + }, + { + "id": "model.license_record.is_valid.create_at.app_error", + "translation": "Няправільнае значэнне для create_at пры загрузцы ліцэнзіі." + }, + { + "id": "model.job.is_valid.type.app_error", + "translation": "Няправільны тып задання." + }, + { + "id": "model.job.is_valid.status.app_error", + "translation": "Няправільны статус задання." + }, + { + "id": "model.job.is_valid.id.app_error", + "translation": "Няправільны ідэнтыфікатар задання." + }, + { + "id": "model.job.is_valid.create_at.app_error", + "translation": "Дата стварэння павінна быць сапраўдным часам." + }, + { + "id": "model.incoming_hook.update_at.app_error", + "translation": "Дата абнаўлення павінна быць сапраўдным часам." + }, + { + "id": "model.incoming_hook.team_id.app_error", + "translation": "Няправільны ID каманды." + }, { "id": "model.incoming_hook.parse_data.app_error", - "translation": "Немагчыма разабраць уваходныя дадзеныя." + "translation": "Немагчыма разабраць уваходныя даныя." }, { "id": "model.incoming_hook.icon_url.app_error", - "translation": "Недапушчальны значок паведамлення." + "translation": "Няправільны значок паведамлення." }, { "id": "model.incoming_hook.create_at.app_error", - "translation": "\"Створана ў\" павінна быць карэктным часам." + "translation": "Дата стварэння павінна быць сапраўдным часам." }, { "id": "model.incoming_hook.channel_id.app_error", - "translation": "Няправiльны ідэнтыфікатар канала." + "translation": "Няправільны ідэнтыфікатар канала." }, { "id": "model.group_syncable.syncable_id.app_error", - "translation": "Няправільны \"ідэнтыфікатар сінхранізацыі\" для сінхранізацыі групы." + "translation": "Няправільны syncable_id для групы, якая сінхранізуецца." }, { "id": "model.group_syncable.group_id.app_error", - "translation": "Няслушная ўласцівасць \"ідэнтыфікатар групы\" для групавой сінхранізацыі." + "translation": "Няправільнае ўласцівасць group_id для групы, якая сінхранізуецца." }, { "id": "model.group_member.user_id.app_error", - "translation": "Няслушная ўласцівасць \"ідэнтыфікатар карыстальніка\" для ўдзельніка групы." + "translation": "Няправільнае ўласцівасць user_id для члена групы." }, { "id": "model.group_member.group_id.app_error", - "translation": "Няслушная ўласцівасць \"ідэнтыфікатар групы\" для ўдзельніка групы." + "translation": "Няправільнае ўласцівасць group_id для члена групы." }, { "id": "model.group.update_at.app_error", - "translation": "Няправільная ўласцівасць \"абноўлена ў\" для групы." + "translation": "Няправільнае ўласцівасць update_at для групы." }, { "id": "model.group.source.app_error", - "translation": "Няслушная ўласцівасць \"крыніца\" для групы." + "translation": "Няправільнае ўласцівасць source для групы." }, { "id": "model.group.remote_id.app_error", - "translation": "Няслушная ўласцівасць \"выдалены ідэнтыфікатар\" для групы." + "translation": "Няправільнае ўласцівасць remote_id для групы." }, { "id": "model.group.name.invalid_length.app_error", - "translation": "Імя павінна мець даўжыню ад 1 да 64 сімвалаў, якія змяшчаюць літары і лічбы ў ніжнім рэгістры." + "translation": "Назва павінна быць ад 1 да 64 малых літар і лічбаў." }, { "id": "model.group.name.invalid_chars.app_error", - "translation": "недапушчальныя сімвалы ва ўласцівасці імя для групы" + "translation": "Няправільныя сімвалы ва ўласцівасці name для групы" }, { "id": "model.group.name.app_error", - "translation": "Няслушная ўласцівасць \"імя\" для групы." + "translation": "Няправільнае ўласцівасць name для групы." }, { "id": "model.group.display_name.app_error", - "translation": "Няслушная ўласцівасць \"адлюстроўванае імя\" для групы." + "translation": "Няправільнае ўласцівасць display_name для групы." }, { "id": "model.group.description.app_error", - "translation": "Няслушная ўласцівасць \"апісанне\" для групы." + "translation": "Няправільнае ўласцівасць description для групы." }, { "id": "model.group.create_at.app_error", - "translation": "Няслушная ўласцівасць \"Створана ў\" для групы." + "translation": "Няправільнае ўласцівасць create_at для групы." }, { "id": "model.file_info.is_valid.user_id.app_error", - "translation": "Недапушчальнае значэнне для часу чакання чытання." + "translation": "Няправільнае значэнне для user_id." }, { "id": "model.file_info.is_valid.update_at.app_error", - "translation": "Недапушчальнае значэнне для часу чакання чытання." + "translation": "Няправільнае значэнне для update_at." }, { "id": "model.file_info.is_valid.post_id.app_error", - "translation": "Недапушчальнае значэнне для часу чакання чытання." + "translation": "Няправільнае значэнне для post_id." }, { "id": "model.file_info.is_valid.path.app_error", - "translation": "Недапушчальнае значэнне для часу чакання чытання." + "translation": "Няправільнае значэнне для path." }, { "id": "model.file_info.is_valid.id.app_error", - "translation": "Недапушчальнае значэнне для ідэнтыфікатара." + "translation": "Няправільнае значэнне для id." }, { "id": "model.file_info.is_valid.create_at.app_error", - "translation": "Недапушчальнае значэнне для create_at." + "translation": "Няправільнае значэнне для create_at." }, { "id": "model.emoji.user_id.app_error", - "translation": "Недапушчальны ідэнтыфікатар стваральніка." + "translation": "Няправільны ідэнтыфікатар стваральніка." }, { "id": "model.emoji.update_at.app_error", - "translation": "\"Абноўлена ў\" павінна быць карэктным часам." + "translation": "Дата абнаўлення павінна быць сапраўдным часам." }, { "id": "model.emoji.name.app_error", - "translation": "Імя павінна мець даўжыню ад 1 да 64 сімвалаў, якія змяшчаюць літары і лічбы ў ніжнім рэгістры." + "translation": "Назва павінна быць ад 1 да 64 малых літар і лічбаў." }, { "id": "model.emoji.id.app_error", - "translation": "Некарэктны ідэнтыфікатар эмоджы." + "translation": "Няправільны ідэнтыфікатар эмодзі." }, { "id": "model.emoji.create_at.app_error", - "translation": "\"Створана ў\" павінна быць карэктным часам." + "translation": "Дата стварэння павінна быць сапраўдным часам." }, { "id": "model.config.is_valid.write_timeout.app_error", - "translation": "Недапушчальнае значэнне для часу чакання запісу." + "translation": "Няправільнае значэнне для часу чакання запісу." }, { "id": "model.config.is_valid.websocket_url.app_error", - "translation": "Спасылка на шлюз WebRTC павінна быць дзейснай і пачынацца з ws:// ці wss://." + "translation": "URL Websocket павінен быць сапраўдным URL і пачынацца з ws:// або wss://." }, { "id": "model.config.is_valid.webserver_security.app_error", - "translation": "Недапушчальнае значэнне налад бяспекі злучэння вэб-сервера." + "translation": "Няправільнае значэнне бяспекі злучэння вэб-сервера." }, { "id": "model.config.is_valid.tls_overwrite_cipher.app_error", - "translation": "Перададзена недапушчальнае значэнне для шыфра перазапісу TLS - сапраўдныя значэнні гл. у дакументацыі." + "translation": "Няправільнае значэнне для перазапісу шыфра TLS - звярніцеся да дакументацыі для атрымання сапраўдных значэнняў." }, { "id": "model.config.is_valid.tls_key_file_missing.app_error", - "translation": "Няслушнае значэнне для файла ключа TLS - альбо выкарыстайце LetsEncrypt, альбо ўкажыце шлях да існуючага файла ключа." + "translation": "Няправільнае значэнне для файла ключа TLS - выкарыстоўвайце LetsEncrypt або ўкажыце шлях да існуючага файла ключа." }, { "id": "model.config.is_valid.tls_cert_file_missing.app_error", - "translation": "Няправільнае значэнне для файла сертыфіката TLS - альбо выкарыстайце LetsEncrypt, альбо ўкажыце шлях да існуючага файла сертыфіката." + "translation": "Няправільнае значэнне для файла сертыфіката TLS - выкарыстоўвайце LetsEncrypt або ўкажыце шлях да існуючага файла сертыфіката." }, { "id": "model.config.is_valid.time_between_user_typing.app_error", - "translation": "Абнаўленні статуту набору тэксту карыстачом не могуць быць гушчару, чым раз у 1000 мілісекунд." + "translation": "Час паміж абнаўленнямі стану набору тэксту карыстальнікам не павінен быць меншым за 1000 мілісекунд." }, { "id": "model.config.is_valid.teammate_name_display.app_error", - "translation": "Няправільны паказ таварыша па камандзе. Павінна быць 'full_name, 'nickname_full_name' ці 'username'." + "translation": "Няправільнае адлюстраванне карыстальніка. Павінна быць 'full_name', 'nickname_full_name' або 'username'." }, { "id": "model.config.is_valid.sql_query_timeout.app_error", - "translation": "Няправільны тайм-аўт запыту для налад SQL. Павінна быць станоўчым лікам." + "translation": "Няправільны час чакання запыту для налад SQL. Павінен быць станоўчым лікам." }, { "id": "model.config.is_valid.sql_max_conn.app_error", - "translation": "Няправільнае значэнне максімальнай колькасці адчыненых злучэнняў у наладах SQL. Лік павінен быць дадатным." + "translation": "Няправільная максімальная колькасць адкрытых злучэнняў для налад SQL. Павінна быць станоўчым лікам." }, { "id": "model.config.is_valid.sql_idle.app_error", - "translation": "Няправільнае значэнне максімальнай колькасці падлучэнняў у рэжыме чакання ў наладах SQL. Павінна быць станоўчым лікам." + "translation": "Няправільны максімальны час прастою злучэння для налад SQL. Павінен быць станоўчым лікам." }, { "id": "model.config.is_valid.sql_driver.app_error", - "translation": "Няправільнае імя драйвера ў наладах SQL. Павінна быць 'mysql' ці 'postgres'." + "translation": "Няправільная назва драйвера для налад SQL. Павінна быць 'postgres'." }, { "id": "model.config.is_valid.sql_data_src.app_error", - "translation": "Няправільная крыніца дадзеных у наладах SQL. Значэнне павінна быць устаноўлена." + "translation": "Няправільная крыніца даных для налад SQL. Павінна быць усталявана." }, { "id": "model.config.is_valid.sql_conn_max_lifetime_milliseconds.app_error", - "translation": "Няправільны максімальны час жыцця злучэння для настроек SQL. Павінна быць неадмоўным лікам." + "translation": "Няправільны максімальны час жыцця злучэння для налад SQL. Павінен быць неадмоўным лікам." }, { "id": "model.config.is_valid.sql_conn_max_idle_time_milliseconds.app_error", - "translation": "Няправільны максімальны час жыцця злучэння для настроек SQL. Павінна быць неадмоўным лікам." + "translation": "Няправільны максімальны час прастою злучэння для налад SQL. Павінен быць неадмоўным лікам." }, { "id": "model.config.is_valid.sitename_length.app_error", - "translation": "Назва сайта павінна быць менш ці роўна {{.MaxLength}} сімвалаў." + "translation": "Назва сайта павінна быць не больш за {{.MaxLength}} сімвалаў." }, { "id": "model.config.is_valid.site_url_email_batching.app_error", - "translation": "Немагчыма ўключыць паштовыя аб'яднанні, калі не зададзены SiteURL." + "translation": "Немагчыма ўключыць пакетную апрацоўку электроннай пошты, калі URL сайта не ўсталяваны." }, { "id": "model.config.is_valid.site_url.app_error", - "translation": "Адрас абразка павінен быць карэктным URL-адрасам і пачынацца з http:// або https://." + "translation": "URL сайта павінен быць сапраўдным URL і пачынацца з http:// або https://." }, { "id": "model.config.is_valid.saml_username_attribute.app_error", - "translation": "Няправільнае значэнне атрыбута Username. Атрыбут павінен быць зададзены." + "translation": "Няправільны атрыбут Імя карыстальніка. Павінен быць усталяваны." }, { "id": "model.config.is_valid.saml_spidentifier_attribute.app_error", - "translation": "Патрабуецца ідэнтыфікатар пастаўшчыка паслуг" + "translation": "Ідэнтыфікатар пастаўшчыка паслуг абавязковы" }, { "id": "model.config.is_valid.saml_public_cert.app_error", - "translation": "Адсутнічае публічны сертыфікат правайдэра. Вы забыліся яго загрузіць?" + "translation": "Публічны сертыфікат пастаўшчыка паслуг адсутнічае. Вы забыліся яго загрузіць?" }, { "id": "model.config.is_valid.saml_private_key.app_error", - "translation": "Прыватны ключ пастаўшчыка службы адсутнічае. Вы забыліся яго загрузіць?" + "translation": "Прыватны ключ пастаўшчыка паслуг адсутнічае. Вы забыліся яго загрузіць?" }, { "id": "model.config.is_valid.saml_idp_url.app_error", - "translation": "URL SAML SSO павінен з'яўляцца карэктным URL і пачынацца з http:// ці https://." + "translation": "URL SAML SSO павінен быць сапраўдным URL і пачынацца з http:// або https://." }, { "id": "model.config.is_valid.saml_idp_descriptor_url.app_error", - "translation": "URL эмітэнта пастаўшчыка уліковых дадзеных павінен з'яўляцца карэктным URL і пачынацца з http:// ці https://." + "translation": "URL выдаўца пастаўшчыка ідэнтыфікацыі не можа быць пустым радком." }, { "id": "model.config.is_valid.saml_idp_cert.app_error", - "translation": "Адсутнічае сертыфікат пастаўшчыка уліковых дадзеных. Вы забыліся яго загрузіць?" + "translation": "Публічны сертыфікат пастаўшчыка ідэнтыфікацыі адсутнічае. Вы забыліся яго загрузіць?" }, { "id": "model.config.is_valid.saml_guest_attribute.app_error", - "translation": "Няправільны атрыбут госця. Павінен быць у форме 'поле=значэнне'." + "translation": "Няправільны атрыбут Госць. Павінен быць у форме 'field=value'." }, { "id": "model.config.is_valid.saml_email_attribute.app_error", - "translation": "Няправільнае значэнне атрыбуту Электронная пошта. Павінна быць зададзена." + "translation": "Няправільны атрыбут Email. Павінен быць усталяваны." }, { "id": "model.config.is_valid.saml_assertion_consumer_service_url.app_error", - "translation": "URL уваходу пастаўшчыка паслуг павінен з'яўляцца карэктным URL-адрасам і пачынацца з http:// або https://." + "translation": "URL для ўваходу пастаўшчыка паслуг павінен быць сапраўдным URL і пачынацца з http:// або https://." }, { "id": "model.config.is_valid.saml_admin_attribute.app_error", - "translation": "Няправільны атрыбут адміністратара. Павінен быць у форме 'поле=значэнне'." + "translation": "Няправільны атрыбут адміністратара. Павінен быць у форме 'field=value'." }, { "id": "model.config.is_valid.restrict_direct_message.app_error", - "translation": "Няправільнае абмежаванне асабістых паведамленняў. Павінна быць 'any' ці 'team'." + "translation": "Няправільнае абмежаванне прамых паведамленняў. Павінна быць 'any' або 'team'." }, { "id": "model.config.is_valid.read_timeout.app_error", - "translation": "Недапушчальнае значэнне для часу чакання чытання." + "translation": "Няправільнае значэнне для часу чакання чытання." }, { "id": "model.config.is_valid.rate_sec.app_error", - "translation": "Няправільнае значэнне \"У секунду\" ў наладах абмежавання хуткасці. Павінна быць станоўчым лікам." + "translation": "Няправільнае значэнне \"У секунду\" для налад абмежавання хуткасці. Павінна быць станоўчым лікам." }, { "id": "model.config.is_valid.rate_mem.app_error", - "translation": "Няправільны памер сховішчы памяці ў наладах абмежавання хуткасці. Павінен быць станоўчым лікам." + "translation": "Няправільны памер сховішча памяці для налад абмежавання хуткасці. Павінен быць станоўчым лікам." }, { "id": "model.config.is_valid.password_length.app_error", - "translation": "Мінімальная даўжыня пароля павінна быць цэлым лікам вялікім ці роўным {{.MinLength}}, а таксама меншым або роўным {{.MaxLength}}." + "translation": "Мінімальная даўжыня пароля павінна быць цэлай лічбай, большай або роўнай {{.MinLength}} і меншай або роўнай {{.MaxLength}}." }, { "id": "model.config.is_valid.message_export.global_relay.smtp_username.app_error", - "translation": "GlobalRelaySettings.SmtpUsername заданні экспарту паведамленняў павінна быць зададзена." + "translation": "GlobalRelaySettings.SmtpUsername задачы экспарту паведамленняў павінен быць устаноўлены." }, { "id": "model.config.is_valid.message_export.global_relay.smtp_password.app_error", - "translation": "GlobalRelaySettings.SmtpPassword заданні экспарту паведамленняў павінен быць зададзены." + "translation": "GlobalRelaySettings.SmtpPassword задачы экспарту паведамленняў павінен быць устаноўлены." }, { "id": "model.config.is_valid.message_export.global_relay.email_address.app_error", - "translation": "GlobalRelaySettings.EmailAddress заданні экспарту паведамленняў павінен быць сапраўдным адрасам электроннай пошты." + "translation": "GlobalRelaySettings.EmailAddress задачы экспарту паведамленняў павінен быць сапраўдным адрасам электроннай пошты." }, { "id": "model.config.is_valid.message_export.global_relay.customer_type.app_error", - "translation": "GlobalRelaySettings.CustomerType экспарту паведамленняў павінен быць усталяваны ў 'A9' ці 'A10'." + "translation": "GlobalRelaySettings.CustomerType задачы экспарту паведамленняў павінен быць устаноўлены на 'A9', 'A10' або 'CUSTOM'." }, { "id": "model.config.is_valid.message_export.global_relay.config_missing.app_error", - "translation": "ExportFormat заданні экспарту паведамленняў устаноўлены на 'globalrelay', але GlobalRelaySettings адсутнічаюць." + "translation": "ExportFormat задачы экспарту паведамленняў устаноўлены на 'globalrelay', але GlobalRelaySettings адсутнічаюць." }, { "id": "model.config.is_valid.message_export.export_type.app_error", - "translation": "ExportFormat заданні экспарту паведамленняў павінен быць 'actiance', 'csv' ці 'globalrelay'." + "translation": "ExportFormat задачы экспарту паведамленняў павінен быць 'actiance', 'csv' або 'globalrelay'." }, { "id": "model.config.is_valid.message_export.export_from.app_error", - "translation": "ExportFromTimestamp заданні экспарту паведамленняў павінна быць адзнакай часу (выяўляецца ў секундах з пачатку эпохі Unix). Толькі паведамленні, адпраўленыя пасля гэтай адзнакі часу, будуць экспартаваны." + "translation": "ExportFromTimestamp задачы экспарту паведамленняў павінен быць часовай маркай (выражанай у секундах з пачатку эпохі Unix). Будуць экспартавацца толькі паведамленні, адпраўленыя пасля гэтай часовай маркі." }, { "id": "model.config.is_valid.message_export.enable.app_error", - "translation": "Параметр EnableExport задання экспарту паведамленняў павінна быць роўна true ці false." + "translation": "Налада EnableExport задачы экспарту паведамленняў павінна быць 'true' або 'false'." }, { "id": "model.config.is_valid.message_export.daily_runtime.app_error", - "translation": "Заданне экспарту паведамлення DailyRuntime павінна быць 24-гадзіннай адзнакай часу ў форме ЧЧ:ММ." + "translation": "DailyRuntime задачы экспарту паведамленняў павінен быць у 24-гадзінным фармаце ЧГ:ХХ." }, { "id": "model.config.is_valid.message_export.batch_size.app_error", - "translation": "BatchSize заданні экспарту паведамленняў павінна быць станоўчым цэлым лікам." + "translation": "BatchSize задачы экспарту паведамленняў павінен быць станоўчай цэлай лічбай." }, { "id": "model.config.is_valid.max_users.app_error", - "translation": "Няправільнае максімальную колькасць карыстальнікаў на каманду ў наладах каманд. Лік павінен быць дадатным." + "translation": "Недапушчальная максімальная колькасць карыстальнікаў на каманду для налад каманды. Павінна быць станоўчая лічба." }, { "id": "model.config.is_valid.max_notify_per_channel.app_error", - "translation": "Няправільнае максімальную колькасць карыстальнікаў на каманду. Лік павінен быць дадатным." + "translation": "Недапушчальная максімальная колькасць апавяшчэнняў на канал для налад каманды. Павінна быць станоўчая лічба." }, { "id": "model.config.is_valid.max_file_size.app_error", - "translation": "Няправільны максімальны памер файла для настроек файла. Павінна быць цэлым лікам большым за нуль." + "translation": "Недапушчальны максімальны памер файла для налад файла. Павінна быць цэлая лічба, большая за нуль." }, { "id": "model.config.is_valid.max_channels.app_error", - "translation": "Няправільнае максімальную колькасць карыстальнікаў на каманду ў наладах каманд. Лік павінен быць дадатным." + "translation": "Недапушчальная максімальная колькасць каналаў на каманду для налад каманды. Павінна быць станоўчая лічба." }, { "id": "model.config.is_valid.max_burst.app_error", - "translation": "Максімальнае перавышэнне паласы прапускання павінна быць больш за нуль." + "translation": "Максімальны памер імпульсу павінен быць больш за нуль." }, { "id": "model.config.is_valid.login_attempts.app_error", - "translation": "Няслушная максімальная колькасць спроб уваходу ў настройках службы. Павінна быць станоўчым лікам." + "translation": "Недапушчальная максімальная колькасць спроб уваходу для налад службы. Павінна быць станоўчая лічба." }, { "id": "model.config.is_valid.localization.available_locales.app_error", - "translation": "Даступныя мовы павінны змяшчаць мову кліента па змаўчанні." + "translation": "Даступныя мовы павінны ўключаць мову кліента па змаўчанні." }, { "id": "model.config.is_valid.listen_address.app_error", - "translation": "Няправільны адрас праслухоўвання ў настройках службы. Павінен быць зададзены." + "translation": "Недапушчальны адрас праслухоўвання для налад службы. Павінен быць устаноўлены." }, { "id": "model.config.is_valid.ldap_username", - "translation": "Патрабуецца поле AD/LDAP \"Username Attribute\"." + "translation": "Поле AD/LDAP \"Username Attribute\" абавязковае." }, { "id": "model.config.is_valid.ldap_sync_interval.app_error", - "translation": "Няправільны інтэрвал сінхранізацыі. Павінен складаць не менш за адну хвіліну." + "translation": "Недапушчальны час інтэрвалу сінхранізацыі. Павінен быць не менш за адну хвіліну." }, { "id": "model.config.is_valid.ldap_server", - "translation": "Патрабуецца поле AD/LDAP \"AD/LDAP Server\"." + "translation": "Поле AD/LDAP \"AD/LDAP Server\" абавязковае." }, { "id": "model.config.is_valid.ldap_security.app_error", - "translation": "Няправільны тып бяспекі злучэння ў настройках AD/LDAP. Павінна быць '', 'TLS', ці 'STARTTLS'." + "translation": "Недапушчальная бяспека злучэння для налад AD/LDAP. Павінна быць '', 'TLS' або 'STARTTLS'." }, { "id": "model.config.is_valid.ldap_max_page_size.app_error", - "translation": "Няправільнае значэнне максімальнага памеру старонкі." + "translation": "Недапушчальнае значэнне максімальнага памеру старонкі." }, { "id": "model.config.is_valid.ldap_login_id", - "translation": "Патрабуецца поле AD/LDAP \"ID Attribute\"." + "translation": "Поле AD/LDAP \"Login ID Attribute\" абавязковае." }, { "id": "model.config.is_valid.ldap_id", - "translation": "Патрабуецца поле AD/LDAP \"ID Attribute\"." + "translation": "Поле AD/LDAP \"ID Attribute\" абавязковае." }, { "id": "model.config.is_valid.ldap_email", - "translation": "Патрабуецца поле AD/LDAP \"Email Attribute\"." + "translation": "Поле AD/LDAP \"Email Attribute\" абавязковае." }, { "id": "model.config.is_valid.ldap_basedn", - "translation": "Патрабуецца поле AD/LDAP \"BaseDN\"." + "translation": "Поле AD/LDAP \"BaseDN\" абавязковае." }, { "id": "model.config.is_valid.import.retention_days_too_low.app_error", - "translation": "Некарэктнае значэнне для RetentionDays. Занадта маленькае значэньне." + "translation": "Недапушчальнае значэнне для RetentionDays. Значэнне занадта нізкае." }, { "id": "model.config.is_valid.import.directory.app_error", - "translation": "Некарэктнае значэнне для Directory." + "translation": "Недапушчальнае значэнне для Directory." }, { "id": "model.config.is_valid.image_proxy_type.app_error", - "translation": "Няправільны тып выявы проксі. Павінна быць 'local' ці 'atmos/camo'." + "translation": "Недапушчальны тып проксі выявы. Павінна быць 'local' або 'atmos/camo'." }, { "id": "model.config.is_valid.group_unread_channels.app_error", - "translation": "Няправільная група непрачытаных каналаў для настроек сэрвісу. Павінна быць 'disabled', 'default_on', ці 'default_off'." + "translation": "Недапушчальная група непрачытаных каналаў для налад службы. Павінна быць 'disabled', 'default_on' або 'default_off'." }, { "id": "model.config.is_valid.file_salt.app_error", - "translation": "Няправільная соль публічнай спасылкі ў наладах файлаў. Павінна складацца з 32 і больш сімвалаў." + "translation": "Недапушчальная соль публічнай спасылкі для налад файлаў. Павінна быць 32 сімвалы або больш." }, { "id": "model.config.is_valid.file_driver.app_error", - "translation": "Няправільнае імя драйвера ў наладах файлаў. Павінна быць 'local' ці 'amazons3'." + "translation": "Недапушчальная назва драйвера для налад файлаў. Павінна быць 'local' або 'amazons3'." }, { "id": "model.config.is_valid.export.retention_days_too_low.app_error", - "translation": "Несапраўднае значэнне для RetentionDays. Значэнне павінна быць больш за 0" + "translation": "Недапушчальнае значэнне для RetentionDays. Значэнне павінна быць больш за 0." }, { "id": "model.config.is_valid.export.directory.app_error", @@ -2293,203 +2293,203 @@ }, { "id": "model.config.is_valid.encrypt_sql.app_error", - "translation": "Няправільны ключ шыфравання ў наладах SQL. Павінен быць 32 знакі або больш." + "translation": "Недапушчальны ключ шыфравання ў стане спакою для налад SQL. Павінна быць 32 сімвалы або больш." }, { "id": "model.config.is_valid.email_security.app_error", - "translation": "Няправільны тып бяспекі злучэння ў настройках пошты. Павінна быць '', 'TLS', ці 'STARTTLS'." + "translation": "Недапушчальная бяспека злучэння для налад электроннай пошты. Павінна быць '', 'TLS' або 'STARTTLS'." }, { "id": "model.config.is_valid.email_notification_contents_type.app_error", - "translation": "Недапушчальны тып змесціва апавяшчэння па электроннай пошце для настроек электроннай пошты. Павінна быць 'full' ці 'generic'." + "translation": "Недапушчальны тып зместу паведамленняў электроннай пошты для налад электроннай пошты. Павінна быць 'full' або 'generic'." }, { "id": "model.config.is_valid.email_batching_interval.app_error", - "translation": "Няправільны інтэрвал адпраўкі паштовых аб'яднанняў у настройках пошты. Павінен складаць 30 секунд і больш." + "translation": "Недапушчальны інтэрвал пакетнай апрацоўкі электроннай пошты для налад электроннай пошты. Павінна быць 30 секунд або больш." }, { "id": "model.config.is_valid.email_batching_buffer_size.app_error", - "translation": "Няправільны памер буфера паштовага аб'яднання ў наладах пошты. Лік павінен быць неадмоўным." + "translation": "Недапушчальны памер буфера пакетнай апрацоўкі электроннай пошты для налад электроннай пошты. Павінна быць нуль або станоўчая лічба." }, { "id": "model.config.is_valid.elastic_search.request_timeout_seconds.app_error", - "translation": "Час чакання запыту Elasticsearch павінна складаць не менш за 1 секунду." + "translation": "Час чакання запыту пошуку павінен быць не менш за 1 секунду." }, { "id": "model.config.is_valid.elastic_search.posts_aggregator_job_start_time.app_error", - "translation": "Параметр Elasticsearch PostsAggregatorJobStartTime павінен быць часам у фармаце \"чч:мм\"." + "translation": "Налада Search PostsAggregatorJobStartTime павінна быць часам у фармаце \"чг:хх\"." }, { "id": "model.config.is_valid.elastic_search.live_indexing_batch_size.app_error", - "translation": "Памер пакета жывога індэксавання Elasticsearch павінен быць не менш за 1." + "translation": "Памер партыі жывога індэксавання павінен быць не менш за 1." }, { "id": "model.config.is_valid.elastic_search.enable_searching.app_error", - "translation": "Для параметра Elasticsearch EnableIndexing павінна быць устаноўлена значэнне true, калі для параметра Elasticsearch SearchEnabled устаноўлена значэнне true" + "translation": "Налада {{.EnableIndexing}} павінна быць устаноўлена на 'true', калі {{.Searching}} устаноўлена на 'true'." }, { "id": "model.config.is_valid.elastic_search.enable_autocomplete.app_error", - "translation": "Параметр Elasticsearch IndexingEnabled павінен мець значэнне true, калі для Elasticsearch AutocompleteEnabled устаноўлена значэнне true" + "translation": "Налада {{.EnableIndexing}} павінна быць устаноўлена на 'true', калі {{.Autocomplete}} устаноўлена на 'true'." }, { "id": "model.config.is_valid.elastic_search.connection_url.app_error", - "translation": "Elastic Search ConnectionUrl setting must be provided when Elastic Search indexing is enabled." + "translation": "Налада Search ConnectionUrl павінна быць паказана, калі індэксаванне ўключана." }, { "id": "model.config.is_valid.elastic_search.aggregate_posts_after_days.app_error", - "translation": "Параметр Elasticsearch AggregatePostsAfterDays павінен быць лікам большым ці роўным 1." + "translation": "Налада Search AggregatePostsAfterDays павінна быць лічбай, большай або роўнай 1." }, { "id": "model.config.is_valid.display.custom_url_schemes.app_error", - "translation": "The custom URL scheme {{.Scheme}} is invalid. Custom URL schemes must start with letter and contain only letters, numbers and hyphen (-)." + "translation": "Карыстальніцкая схема URL {{.Scheme}} з'яўляецца недапушчальнай. Карыстальніцкія схемы URL павінны пачынацца з літары і ўтрымліваць толькі літары, лічбы, плюс (+), кропку (.) і працяжнік (-)." }, { "id": "model.config.is_valid.directory.app_error", - "translation": "Няправільны каталог лакальнага сховішча. Павінен быць непусты радок." + "translation": "Недапушчальны каталог лакальнага сховішча. Павінен быць непусты радок." }, { "id": "model.config.is_valid.data_retention.message_retention_days_too_low.app_error", - "translation": "Тэрмін захоўвання паведамлення павінен складаць адзін дзень ці больш." + "translation": "Колькасць дзён захоўвання паведамленняў не можа быць меншай за 0." }, { "id": "model.config.is_valid.data_retention.file_retention_days_too_low.app_error", - "translation": "Тэрмін захоўвання файла павінен быць адзін дзень ці даўжэй." + "translation": "Колькасць дзён захоўвання файлаў не можа быць меншай за 0." }, { "id": "model.config.is_valid.data_retention.deletion_job_start_time.app_error", - "translation": "Час пачатку захавання даных павінен быць 24-гадзіннай адзнакай у фармаце ЧЧ:ММ." + "translation": "Час пачатку задачы захоўвання дадзеных павінен быць у 24-гадзінным фармаце ЧГ:ХХ." }, { "id": "model.config.is_valid.collapsed_threads.autofollow.app_error", - "translation": "Для ўключэння CollapsedThreads павінна быць усталявана значэнне ThreadAutoFollow" + "translation": "ThreadAutoFollow павінна быць 'true', каб уключыць CollapsedThreads." }, { "id": "model.config.is_valid.collapsed_threads.app_error", - "translation": "Налада CollapsedThreads павінна быць альбо disabled, альбо default_on, альбо default_off" + "translation": "Налада CollapsedThreads павінна быць 'disabled', 'default_on' або 'default_off'." }, { "id": "model.config.is_valid.cluster_email_batching.app_error", - "translation": "Немагчыма ўключыць пакетную апрацоўку пошты пры ўключанай кластарызацыі." + "translation": "Немагчыма ўключыць пакетную апрацоўку электроннай пошты пры ўключанай кластэрызацыі." }, { "id": "model.config.is_valid.atmos_camo_image_proxy_url.app_error", - "translation": "Недапушчальны RemoteImageProxyURL для atmos/camo. Павінен быць устаноўлены на ваш агульны ключ." + "translation": "Недапушчальны RemoteImageProxyURL для atmos/camo. Павінна быць устаноўлена ваша агульная ключ." }, { "id": "model.config.is_valid.atmos_camo_image_proxy_options.app_error", - "translation": "Недапушчальныя RemoteImageProxyOptions для atmos/camo. Павінен быць устаноўлены на ваш агульны ключ." + "translation": "Недапушчальныя RemoteImageProxyOptions для atmos/camo. Павінна быць устаноўлена ваша агульная ключ." }, { "id": "model.config.is_valid.allow_cookies_for_subdomains.app_error", - "translation": "Для дазволу файлаў cookie для паддаменаў неабходна ўсталяваць SiteURL." + "translation": "Дазвол файлаў cookie для паддаменаў патрабуе налады SiteURL." }, { "id": "model.compliance.is_valid.start_end_at.app_error", - "translation": "\"Да\" павінна быць больш, чым \"З\"." + "translation": "Час \"Да\" павінен быць большым за час \"Ад\"." }, { "id": "model.compliance.is_valid.start_at.app_error", - "translation": "\"З\" павінна быць карэктным часам." + "translation": "Час \"Ад\" павінен быць сапраўдным." }, { "id": "model.compliance.is_valid.end_at.app_error", - "translation": "\"Да\" павінна быць карэктным часам." + "translation": "Час \"Да\" павінен быць сапраўдным." }, { "id": "model.compliance.is_valid.create_at.app_error", - "translation": "\"Створана ў\" павінна быць карэктным часам." + "translation": "Час стварэння павінен быць сапраўдным." }, { "id": "model.command_hook.id.app_error", - "translation": "Недапушчальны ідэнтыфікатар Хука каманды." + "translation": "Недапушчальны ідэнтыфікатар хука каманды." }, { "id": "model.command_hook.create_at.app_error", - "translation": "\"Створана ў\" павінна быць карэктным часам." + "translation": "Час стварэння павінен быць сапраўдным." }, { "id": "model.command.is_valid.url_http.app_error", - "translation": "Недапушчальны URL. Адрас URL павінен быць правільны і пачынацца з http:// ці https://." + "translation": "Недапушчальны URL. Павінен быць сапраўдным URL і пачынацца з http:// або https://." }, { "id": "model.command.is_valid.update_at.app_error", - "translation": "\"Абноўлена ў\" павінна быць карэктным часам." + "translation": "Час абнаўлення павінен быць сапраўдным." }, { "id": "model.command.is_valid.create_at.app_error", - "translation": "\"Створана ў\" павінна быць карэктным часам." + "translation": "Дата стварэння павінна быць сапраўдным часам." }, { "id": "model.cluster.is_valid.type.app_error", - "translation": "Тып павінен быць зададзены." + "translation": "Тып павінен быць усталяваны." }, { "id": "model.cluster.is_valid.name.app_error", - "translation": "ClusterName павінен быць зададзены." + "translation": "ClusterName павінен быць усталяваны." }, { "id": "model.cluster.is_valid.last_ping_at.app_error", - "translation": "LastPingAt павінен быць зададзены." + "translation": "LastPingAt павінен быць усталяваны." }, { "id": "model.cluster.is_valid.hostname.app_error", - "translation": "Імя хаста павінна быць зададзена." + "translation": "Імя хоста павінна быць усталявана." }, { "id": "model.cluster.is_valid.create_at.app_error", - "translation": "CreateAt павінен быць зададзены." + "translation": "CreateAt павінен быць усталяваны." }, { "id": "model.channel_member.is_valid.unread_level.app_error", - "translation": "Немагчыма пазначыць як непрачытанае: няслушны ўзровень дазволу." + "translation": "Несапраўдны ўзровень пазначэння як непрачытанае." }, { "id": "model.channel_member.is_valid.push_level.app_error", - "translation": "Няправільны ўзровень push-апавяшчэнняў." + "translation": "Несапраўдны ўзровень push-апавяшчэнняў." }, { "id": "model.channel_member.is_valid.ignore_channel_mentions_value.app_error", - "translation": "Няправільны статус ігнаравання згадак у канале." + "translation": "Несапраўдны статус ігнаравання згадак канала." }, { "id": "model.channel_member.is_valid.email_value.app_error", - "translation": "Няправільнае значэнне апавяшчэння па электроннай пошце." + "translation": "Несапраўднае значэнне апавяшчэння па электроннай пошце." }, { "id": "model.channel.is_valid.update_at.app_error", - "translation": "\"Абноўлена ў\" павінна быць карэктным часам." + "translation": "Час абнаўлення павінен быць сапраўдным часам." }, { "id": "model.channel.is_valid.name.app_error", - "translation": "Некарэктнае імя канала. Забаронена выкарыстоўваць ідэнтыфікатары карыстальнікаў у якасці імя канала (акрамя каналаў асабістых паведамленняў)." + "translation": "Назвы каналаў не могуць быць у шаснаццатковым фармаце. Калі ласка, увядзіце іншую назву канала." }, { "id": "model.channel.is_valid.create_at.app_error", - "translation": "\"Створана ў\" павінна быць карэктным часам." + "translation": "Дата стварэння павінна быць сапраўдным часам." }, { "id": "model.authorize.is_valid.expires.app_error", - "translation": "Тэрмін дзеяння павінен быць устаноўлены." + "translation": "Тэрмін дзеяння павінен быць усталяваны." }, { "id": "model.authorize.is_valid.create_at.app_error", - "translation": "\"Створана ў\" павінна быць карэктным часам." + "translation": "Дата стварэння павінна быць сапраўдным часам." }, { "id": "migrations.worker.run_migration.unknown_key", - "translation": "Немагчыма запусціць заданне міграцыі з-за невядомага ключа міграцыі." + "translation": "Немагчыма запусціць задачу міграцыі з-за невядомага ключа міграцыі." }, { "id": "migrations.worker.run_advanced_permissions_phase_2_migration.invalid_progress", - "translation": "Міграцыя не ўдалася з-за няправільных звестак аб прагрэсе." + "translation": "Міграцыя не ўдалася з-за несапраўдных даных прагрэсу." }, { "id": "migrations.system.save.app_error", - "translation": "Узнікла памылка пры захаванні ўласцівасці сістэмы." + "translation": "Мы сутыкнуліся з памылкай пры захаванні ўласцівасці сістэмы." }, { "id": "mfa.mfa_disabled.app_error", - "translation": "Шматфактарная аўтэнтыфікацыя была адключаная на гэтым серверы." + "translation": "Шматфактарная аўтэнтыфікацыя адключана на гэтым серверы." }, { "id": "mfa.generate_qr_code.create_code.app_error", @@ -2509,87 +2509,87 @@ }, { "id": "manaultesting.manual_test.parse.app_error", - "translation": "Немагчыма разабраць URL." + "translation": "Немагчыма апрацаваць URL." }, { "id": "jobs.set_job_error.update.error", - "translation": "Не ўдалося ўстанавіць статус задання на памылку" + "translation": "Не атрымалася ўсталяваць статус задання на памылку" }, { "id": "jobs.request_cancellation.status.error", - "translation": "Не ўдалося запытаць адмену для задання, якое не знаходзіцца ў стане адмены." + "translation": "Немагчыма запытаць адмену для задання, якое не знаходзіцца ў стане, які дазваляе адмену." }, { "id": "interactive_message.generate_trigger_id.signing_failed", - "translation": "Не атрымалася падпісаць згенераваны ID трыгера для інтэрактыўнага дыялогу." + "translation": "Не атрымалася падпісаць згенераваны ідэнтыфікатар трыгера для інтэрактыўнага дыялогу." }, { "id": "interactive_message.decode_trigger_id.verify_signature_failed", - "translation": "Памылка пацверджання подпісу ID трыгера для інтэрактыўнага дыялогу." + "translation": "Не ўдалося праверыць подпіс ідэнтыфікатара трыгера для інтэрактыўнага дыялогу." }, { "id": "interactive_message.decode_trigger_id.signature_decode_failed", - "translation": "Не атрымалася дэкадаваць base64 подпіс ID трыгера для інтэрактыўнага дыялогу." + "translation": "Не атрымалася дэкадаваць base64 подпісу ідэнтыфікатара трыгера для інтэрактыўнага дыялогу." }, { "id": "interactive_message.decode_trigger_id.missing_data", - "translation": "Трыгер ID не ўтрымоўвае патрабаваных дадзеных для інтэрактыўнага дыялогу." + "translation": "Ідэнтыфікатар трыгера не мае неабходных даных для інтэрактыўнага дыялогу." }, { "id": "interactive_message.decode_trigger_id.expired", - "translation": "ID трыгера для інтэрактыўнага дыялогу скончыўся. ID трыгераў сапраўдныя на працягу максімум {{.Seconds}} секунд." + "translation": "Ідэнтыфікатар трыгера для інтэрактыўнага дыялогу пратэрмінаваны. Ідэнтыфікатары трыгераў дзейнічаюць максімум {{.Duration}}." }, { "id": "interactive_message.decode_trigger_id.base64_decode_failed_signature", - "translation": "Не атрымалася дэкадаваць base64 подпіс ID трыгера для інтэрактыўнага дыялогу." + "translation": "Не атрымалася дэкадаваць base64 подпісу ідэнтыфікатара трыгера для інтэрактыўнага дыялогу." }, { "id": "interactive_message.decode_trigger_id.base64_decode_failed", - "translation": "Не атрымалася дэкадаваць base64 згенераваны ID трыгера для інтэрактыўнага дыялогу." + "translation": "Не атрымалася дэкадаваць base64 ідэнтыфікатара трыгера для інтэрактыўнага дыялогу." }, { "id": "import_process.worker.do_job.open_file", - "translation": "Немагчыма апрацаваць імпарт: не ўдалося адкрыць файл." + "translation": "Немагчыма апрацаваць імпарт: не атрымалася адкрыць файл." }, { "id": "import_process.worker.do_job.missing_jsonl", - "translation": "Не ўдалося апрацаваць імпарт: JSONL файл адсутнічае." + "translation": "Немагчыма апрацаваць імпарт: адсутнічае файл JSONL." }, { "id": "import_process.worker.do_job.missing_file", - "translation": "Не ўдалося апрацаваць імпарт: адсутнічае параметр import_file." + "translation": "Немагчыма апрацаваць імпарт: адсутнічае параметр import_file." }, { "id": "import_process.worker.do_job.file_exists", - "translation": "Не ўдалося апрацаваць імпарт: файл не існуе." + "translation": "Немагчыма апрацаваць імпарт: файл не існуе." }, { "id": "groups.unsupported_syncable_type", - "translation": "Непадтрымоўваны сінхранізаваны тып '{{.Value}}'." + "translation": "Непадтрымліваны тып для сінхранізацыі '{{.Value}}'." }, { "id": "group_not_associated_to_synced_team", - "translation": "Група не можа быць звязана з каналам, пакуль яна не будзе спачатку звязана з бацькоўскай сінхранізаванай па групе камандай." + "translation": "Групу нельга звязаць з каналам, пакуль яна не будзе спачатку звязана з бацькоўскай камандай, сінхранізаванай па групе." }, { "id": "ent.user.complete_switch_with_oauth.blank_email.app_error", - "translation": "Немагчыма завяршыць уваход у SAML з пустым адрасам электроннай пошты." + "translation": "Немагчыма завяршыць уваход SAML з пустым адрасам электроннай пошты." }, { "id": "ent.saml.service_disable.app_error", - "translation": "SAML 2.0 не наладжаны ці не падтрымліваецца на гэтым серверы." + "translation": "SAML 2.0 не настроены або не падтрымліваецца на гэтым серверы." }, { "id": "ent.saml.save_user.username_exists.saml_app_error", - "translation": "Уліковы запіс з такім імем карыстальніка ўжо існуе. Калі ласка, звяжыцеся з Адміністратарам." + "translation": "Уліковы запіс з такім імем карыстальніка ўжо існуе. Калі ласка, звярніцеся да вашага адміністратара." }, { "id": "ent.saml.save_user.email_exists.saml_app_error", - "translation": "Гэты ўліковы запіс не выкарыстоўвае аўтэнтыфікацыю SAML. Калі ласка, увайдзіце, выкарыстоўваючы электронную пошту і пароль." + "translation": "Гэты ўліковы запіс не выкарыстоўвае SAML аўтэнтыфікацыю. Калі ласка, увайдзіце, выкарыстоўваючы электронную пошту і пароль." }, { "id": "ent.saml.metadata.app_error", - "translation": "Адбылася памылка пры пабудове метададзеных пастаўшчыка паслуг." + "translation": "Адбылася памылка пры стварэнні метададзеных пастаўшчыка паслуг." }, { "id": "ent.saml.license_disable.app_error", @@ -2597,39 +2597,39 @@ }, { "id": "ent.saml.do_login.parse.app_error", - "translation": "Узнікла памылка падчас разбору адказу ад пастаўшчыка уліковых запісаў. Калі ласка, звяжыцеся з сістэмным адміністратарам." + "translation": "Адбылася памылка пры разборы адказу ад пастаўшчыка ідэнтыфікацыі. Калі ласка, звярніцеся да вашага сістэмнага адміністратара." }, { "id": "ent.saml.do_login.invalid_time.app_error", - "translation": "Мы атрымалі няправільны час у адказе пастаўшчыка ідэнтыфікацыйнай інфармацыі. Калі ласка, звярніцеся да сістэмнага адміністратара." + "translation": "Мы атрымалі несапраўдны час у адказе ад пастаўшчыка ідэнтыфікацыі. Калі ласка, звярніцеся да вашага сістэмнага адміністратара." }, { "id": "ent.saml.do_login.invalid_signature.app_error", - "translation": "Узнікла памылка падчас праверкі адказу ад правайдэра ўліковых запісаў. Калі ласка, звяжыцеся з сістэмным адміністратарам." + "translation": "Мы атрымалі несапраўдны подпіс у адказе ад пастаўшчыка ідэнтыфікацыі. Калі ласка, звярніцеся да вашага сістэмнага адміністратара." }, { "id": "ent.saml.do_login.empty_response.app_error", - "translation": "Атрыманы пусты адказ ад пастаўшчыка уліковых запісаў." + "translation": "Мы атрымалі пусты адказ ад пастаўшчыка ідэнтыфікацыі." }, { "id": "ent.saml.configure.not_encrypted_response.app_error", - "translation": "Спроба ўваходу з выкарыстаннем SAML не атрымалася, бо адказ пастаўшчыка уліковых запісаў не зашыфраваны. Калі ласка, звяжыцеся з сістэмным адміністратарам." + "translation": "Уваход праз SAML не ўдаўся, бо адказ пастаўшчыка ідэнтыфікацыі не зашыфраваны. Калі ласка, звярніцеся да вашага сістэмнага адміністратара." }, { "id": "ent.saml.configure.load_private_key.app_error", - "translation": "Спроба ўваходу з выкарыстаннем SAML не ўдалася з-за таго, што не быў знойдзены прыватны ключ пастаўшчыка службы. Калі ласка, звяжыцеся з сістэмным адміністратарам." + "translation": "Уваход праз SAML не ўдаўся, бо прыватны ключ пастаўшчыка паслуг не знойдзены. Калі ласка, звярніцеся да вашага сістэмнага адміністратара." }, { "id": "ent.saml.configure.encryption_not_enabled.app_error", - "translation": "Спроба ўваходу з выкарыстаннем SAML не атрымалася з прычыны адключанага шыфравання. Калі ласка, звяжыцеся з сістэмным адміністратарам." + "translation": "Уваход праз SAML не ўдаўся, бо шыфраванне не ўключана. Калі ласка, звярніцеся да вашага сістэмнага адміністратара." }, { "id": "ent.saml.build_request.app_error", - "translation": "Падчас ініцыялізацыі запыту да правайдэра ідэнтыфікацыі адбылася памылка. Калі ласка, звярніцеся да сістэмнага адміністратара." + "translation": "Адбылася памылка пры ініцыялізацыі запыту да пастаўшчыка ідэнтыфікацыі. Калі ласка, звярніцеся да вашага сістэмнага адміністратара." }, { "id": "ent.saml.attribute.app_error", - "translation": "Спроба ўваходу з выкарыстаннем SAML не ўдалася з-за некарэктнага атрыбуту. Калі ласка, звяжыцеся з сістэмным адміністратарам." + "translation": "Уваход праз SAML не ўдаўся, бо адзін з атрыбутаў няправільны. Калі ласка, звярніцеся да вашага сістэмнага адміністратара." }, { "id": "ent.migration.migratetosaml.username_already_used_by_other_user", @@ -2641,151 +2641,151 @@ }, { "id": "ent.migration.migratetosaml.email_already_used_by_other_user", - "translation": "Электронная пошта ўжо выкарыстоўваецца іншым карыстальнікам SAML." + "translation": "Адрас электроннай пошты ўжо выкарыстоўваецца іншым SAML-карыстальнікам." }, { "id": "ent.migration.migratetoldap.user_not_found", - "translation": "Не атрымалася знайсці карыстальніка на серверы AD/LDAP: " + "translation": "Немагчыма знайсці карыстальніка на серверы AD/LDAP: " }, { "id": "ent.migration.migratetoldap.duplicate_field", - "translation": "Немагчыма перанесці карыстальнікаў AD/LDAP з пазначаным полем. Выяўлена дубліраванне запісаў. Калі ласка, выдаліце ўсе дублікаты і паспрабуйце яшчэ раз." + "translation": "Немагчыма міграваць карыстальнікаў AD/LDAP з указаным полем. Выяўлена дублікат запісу. Калі ласка, выдаліце ўсе дублікаты і паспрабуйце зноў." }, { "id": "ent.message_export.run_export.app_error", - "translation": "Не ўдалося выбраць дадзеныя экспарту паведамленняў." + "translation": "Немагчыма выбраць даныя для экспарту паведамленняў." }, { "id": "ent.ldap_groups.reachable_groups_error", - "translation": "памылка пры атрыманні груп для карыстальніка" + "translation": "памылка атрымання груп для карыстальніка" }, { "id": "ent.ldap_groups.no_rows", - "translation": "не знойдзена груп з супадальным uid" + "translation": "групы з адпаведным uid не знойдзены" }, { "id": "ent.ldap_groups.members_of_group_error", - "translation": "памылка пры атрыманні ўдзельнікаў групы" + "translation": "памылка атрымання членаў групы" }, { "id": "ent.ldap_groups.groups_search_error", - "translation": "памылка пры атрыманні груп ldap" + "translation": "памылка атрымання груп ldap" }, { "id": "ent.ldap_groups.group_search_error", - "translation": "памылка пры атрыманні групы ldap" + "translation": "памылка атрымання групы ldap" }, { "id": "ent.ldap.validate_guest_filter.app_error", - "translation": "Няверны фільтр госця AD/LDAP." + "translation": "Няправільны фільтр гасцей AD/LDAP." }, { "id": "ent.ldap.validate_admin_filter.app_error", - "translation": "Няправільны фільтр адміна AD/LDAP." + "translation": "Няправільны фільтр адміністратара AD/LDAP." }, { "id": "ent.ldap.syncronize.search_failure_size_exceeded.app_error", - "translation": "Перавышаны мяжа памеру. Паспрабуйце праверыць [максімальны памер старонкі](https://docs.mattermost.com/deployment/sso-ldap.html#i-see-the-log-error-ldap-result-code-4-size-limit-exceeded) ." + "translation": "Перавышаны ліміт памеру. Паспрабуйце павялічыць настройку \"Максімальны памер старонкі\". Больш падрабязна пра гэта чытайце на сайце https://docs.mattermost.com/onboard/ad-ldap.html#i-see-the-log-error-ldap-result-code-4-size-limit-exceeded." }, { "id": "ent.ldap.syncronize.search_failure.app_error", - "translation": "Не ўдалося знайсці карыстальнікаў у AD/LDAP. Праверце, ці можа сервер Mattermost падключыцца да вашага сервера AD/LDAP, і паспрабуйце зноў." + "translation": "Немагчыма знайсці карыстальнікаў у AD/LDAP. Праверце, ці можа сервер Mattermost падключыцца да вашага сервера AD/LDAP, і паспрабуйце зноў." }, { "id": "app.webhooks.update_incoming.app_error", - "translation": "Не атрымалася абнавіць уваходны вебхук." + "translation": "Немагчыма абнавіць уваходны вэб-хук." }, { "id": "app.webhooks.save_outgoing.override.app_error", - "translation": "Вы не можаце перазапісаць наяўны выходны вебхук." + "translation": "Вы не можаце перазапісаць існуючы выходны вэб-хук." }, { "id": "app.webhooks.save_outgoing.app_error", - "translation": "Не атрымалася захаваць выходны вебхук." + "translation": "Немагчыма захаваць выходны вэб-хук." }, { "id": "app.webhooks.save_incoming.existing.app_error", - "translation": "Вы не можаце перазапісаць існуючы які ўваходзіць вебхук." + "translation": "Вы не можаце перазапісаць існуючы ўваходны вэб-хук." }, { "id": "app.webhooks.save_incoming.app_error", - "translation": "Немагчыма захаваць уваходны вебхук." + "translation": "Немагчыма захаваць уваходны вэб-хук." }, { "id": "app.webhooks.permanent_delete_outgoing_by_user.app_error", - "translation": "Немагчыма выдаліць вебхук." + "translation": "Немагчыма выдаліць вэб-хук." }, { "id": "app.webhooks.permanent_delete_outgoing_by_channel.app_error", - "translation": "Немагчыма выдаліць вебхук." + "translation": "Немагчыма выдаліць вэб-хук." }, { "id": "app.webhooks.permanent_delete_incoming_by_user.app_error", - "translation": "Немагчыма выдаліць вебхук." + "translation": "Немагчыма выдаліць вэб-хук." }, { "id": "app.webhooks.permanent_delete_incoming_by_channel.app_error", - "translation": "Немагчыма выдаліць вебхук." + "translation": "Немагчыма выдаліць вэб-хук." }, { "id": "app.webhooks.get_outgoing_by_team.app_error", - "translation": "Не ўдалося атрымаць вэбхукі." + "translation": "Немагчыма атрымаць вэб-хукі." }, { "id": "app.webhooks.get_outgoing_by_channel.app_error", - "translation": "Не ўдалося атрымаць вэбхукі." + "translation": "Немагчыма атрымаць вэб-хукі." }, { "id": "app.webhooks.get_outgoing.app_error", - "translation": "Не ўдалося атрымаць вэбхук." + "translation": "Немагчыма атрымаць вэб-хук." }, { "id": "app.webhooks.get_incoming_by_user.app_error", - "translation": "Не ўдалося атрымаць вэбхук." + "translation": "Немагчыма атрымаць вэб-хук." }, { "id": "app.webhooks.get_incoming_by_channel.app_error", - "translation": "Не ўдалося атрымаць вэбхукі." + "translation": "Немагчыма атрымаць вэб-хукі." }, { "id": "app.webhooks.get_incoming.app_error", - "translation": "Не ўдалося атрымаць вэбхук." + "translation": "Немагчыма атрымаць вэб-хук." }, { "id": "app.webhooks.delete_outgoing.app_error", - "translation": "Немагчыма выдаліць вебхук." + "translation": "Немагчыма выдаліць вэб-хук." }, { "id": "app.webhooks.delete_incoming.app_error", - "translation": "Немагчыма выдаліць вебхук." + "translation": "Немагчыма выдаліць вэб-хук." }, { "id": "app.webhooks.analytics_outgoing_count.app_error", - "translation": "Немагчыма злічыць выходныя вэб-хукі." + "translation": "Немагчыма падлічыць выходныя вэб-хукі." }, { "id": "app.webhooks.analytics_incoming_count.app_error", - "translation": "Немагчыма злічыць якія ўваходзяць вэб-хукі." + "translation": "Немагчыма падлічыць уваходныя вэб-хукі." }, { "id": "app.valid_password_generic.app_error", - "translation": "Пароль несапраўдны" + "translation": "Пароль несапраўдны." }, { "id": "app.user_terms_of_service.save.app_error", - "translation": "Не ўдалося захаваць умовы выкарыстання." + "translation": "Немагчыма захаваць умовы абслугоўвання." }, { "id": "app.user_terms_of_service.get_by_user.no_rows.app_error", - "translation": "Умовы выкарыстання не знойдзены." + "translation": "Умовы абслугоўвання не знойдзены." }, { "id": "app.user_terms_of_service.get_by_user.app_error", - "translation": "Не ўдалося атрымаць Умовы выкарыстання." + "translation": "Немагчыма атрымаць умовы абслугоўвання." }, { "id": "app.user_terms_of_service.delete.app_error", - "translation": "Не атрымалася выдаліць умовы выкарыстання." + "translation": "Немагчыма выдаліць умовы абслугоўвання." }, { "id": "app.user_access_token.update_token_enable.app_error", @@ -2797,51 +2797,51 @@ }, { "id": "app.user_access_token.search.app_error", - "translation": "Пры пошуку токенаў доступу карыстальніка адбылася памылка." + "translation": "Мы сутыкнуліся з памылкай пры пошуку токенаў доступу карыстальніка." }, { "id": "app.user_access_token.save.app_error", - "translation": "Не ўдалося захаваць персанальны токен доступу." + "translation": "Немагчыма захаваць асабісты токен доступу." }, { "id": "app.user_access_token.invalid_or_missing", - "translation": "Няверны ці адсутны токен." + "translation": "Няправільны або адсутны токен." }, { "id": "app.user_access_token.get_by_user.app_error", - "translation": "Не ўдалося атрымаць персанальныя токены доступу па карыстальніку." + "translation": "Немагчыма атрымаць асабістыя токены доступу па карыстальніку." }, { "id": "app.user_access_token.get_all.app_error", - "translation": "Не ўдалося атрымаць усе персанальныя токены доступу." + "translation": "Немагчыма атрымаць усе асабістыя токены доступу." }, { "id": "app.user_access_token.disabled", - "translation": "Такены асабістага доступу на гэтым серверы адключаныя. Калі ласка, звяжыцеся з вашым сістэмным адміністратарам для дэталяў." + "translation": "Асабістыя токены доступу адключаны на гэтым серверы. Калі ласка, звярніцеся да вашага сістэмнага адміністратара для атрымання падрабязнай інфармацыі." }, { "id": "app.user_access_token.delete.app_error", - "translation": "Немагчыма выдаліць персанальны токен доступу." + "translation": "Немагчыма выдаліць асабісты токен доступу." }, { "id": "app.user.verify_email.app_error", - "translation": "Не ўдалося абнавіць поле праверкі email-адрасу." + "translation": "Немагчыма абнавіць поле пацвярджэння электроннай пошты." }, { "id": "app.user.update_update.app_error", - "translation": "Не ўдалося абнавіць дату апошняй змены дадзеных карыстальніка." + "translation": "Немагчыма абнавіць дату апошняга абнаўлення карыстальніка." }, { "id": "app.user.update_threads_read_for_user.app_error", - "translation": "Не атрымалася пазначыць усе карыстацкія абмеркаванні як прачытаныя" + "translation": "Немагчыма пазначыць усе гілкі карыстальніка як прачытаныя." }, { "id": "app.user.update_thread_read_for_user.app_error", - "translation": "Не ўдалося абнавіць статус чытання для абмеркавання" + "translation": "Немагчыма абнавіць стан прачытання для гілкі." }, { "id": "app.user.update_thread_follow_for_user.app_error", - "translation": "Немагчыма абнавіць стан адсочвання для абмеркавання" + "translation": "Немагчыма абнавіць стан адсочвання для гілкі." }, { "id": "app.user.update_failed_pwd_attempts.app_error", @@ -2849,35 +2849,35 @@ }, { "id": "app.user.update_auth_data.email_exists.app_error", - "translation": "Немагчыма пераключыць уліковы запіс на {{.Service}}. Уліковы запіс, які выкарыстоўвае email-адрас {{.Email}} ужо існуе." + "translation": "Немагчыма пераключыць уліковы запіс на {{.Service}}. Уліковы запіс з адрасам {{.Email}} ужо існуе." }, { "id": "app.user.update_auth_data.app_error", - "translation": "Не ўдалося абнавіць дадзеныя для аўтэнтыфікацыі." + "translation": "Немагчыма абнавіць даныя аўтэнтыфікацыі." }, { "id": "app.user.update_active_for_multiple_users.updating.app_error", - "translation": "Не ўдалося дэактываваць гасцей." + "translation": "Немагчыма дэактываваць гасцей." }, { "id": "app.user.update.finding.app_error", - "translation": "Узнікла памылка пошуку ўліковага запісу." + "translation": "Мы сутыкнуліся з памылкай пры пошуку ўліковага запісу." }, { "id": "app.user.update.find.app_error", - "translation": "Не ўдалося знайсці дзейны ўліковы запіс для абнаўлення." + "translation": "Немагчыма знайсці існуючы ўліковы запіс для абнаўлення." }, { "id": "app.user.store_is_empty.app_error", - "translation": "Не ўдалося праверыць, ці пуста сховішча карыстальнікаў." + "translation": "Немагчыма праверыць, ці пусты сховішча карыстальнікаў." }, { "id": "app.user.send_auto_response.app_error", - "translation": "Не ўдалося адправіць аўтаматычны адказ ад карыстальніка." + "translation": "Немагчыма адправіць аўтаматычны адказ ад карыстальніка." }, { "id": "app.user.search.app_error", - "translation": "Немагчыма знайсці карыстальніка, які адпавядае крытэрам пошуку." + "translation": "Немагчыма знайсці карыстальніка, які адпавядае параметрам пошуку." }, { "id": "app.user.save.username_exists.app_error", @@ -2885,19 +2885,19 @@ }, { "id": "app.user.save.existing.app_error", - "translation": "Неабходна выклікаць абнаўленне для існуючага карыстальніка." + "translation": "Трэба выклікаць абнаўленне для існуючага карыстальніка." }, { "id": "app.user.save.email_exists.app_error", - "translation": "Уліковы запіс з такім email-адрасам ужо існуе." + "translation": "Уліковы запіс з такім адрасам электроннай пошты ўжо існуе." }, { "id": "app.user.save.app_error", - "translation": "Не ўдалося захаваць уліковы запіс." + "translation": "Немагчыма захаваць уліковы запіс." }, { "id": "app.user.promote_guest.user_update.app_error", - "translation": "Не ўдалося абнавіць карыстальніка." + "translation": "Немагчыма абнавіць карыстальніка." }, { "id": "app.user.permanentdeleteuser.internal_error", @@ -2905,203 +2905,203 @@ }, { "id": "app.user.permanent_delete.app_error", - "translation": "Не атрымалася выдаліць існуючы ўліковы запіс." + "translation": "Немагчыма выдаліць існуючы ўліковы запіс." }, { "id": "app.user.missing_account.const", - "translation": "Карыстальнік не знойдзены." + "translation": "Немагчыма знайсці карыстальніка." }, { "id": "app.user.get_users_batch_for_indexing.get_users.app_error", - "translation": "Не атрымалася атрымаць пакет карыстачоў для індэксацыі." + "translation": "Немагчыма атрымаць партыю карыстальнікаў для індэксацыі." }, { "id": "app.user.get_unread_count.app_error", - "translation": "Не атрымалася атрымаць колькасць непрачытаных паведамленняў для карыстальніка." + "translation": "Немагчыма атрымаць колькасць непрачытаных паведамленняў для карыстальніка." }, { "id": "app.user.get_total_users_count.app_error", - "translation": "Не ўдалося падлічыць карыстальнікаў." + "translation": "Немагчыма падлічыць карыстальнікаў." }, { "id": "app.user.get_threads_for_user.not_found", - "translation": "Абмеркаванне карыстальніка не існуе ці не адсочваецца" + "translation": "Гілка карыстальніка не існуе або не адсочваецца." }, { "id": "app.user.get_threads_for_user.app_error", - "translation": "Не ўдалося атрымаць абмеркавання карыстальніка" + "translation": "Немагчыма атрымаць гілкі карыстальніка." }, { "id": "app.user.get_thread_membership_for_user.not_found", - "translation": "Удзел карыстальніка ў абмеркаванні не існуе" + "translation": "Прыналежнасць карыстальніка да гілкі не існуе." }, { "id": "app.user.get_thread_membership_for_user.app_error", - "translation": "Не ўдалося атрымаць удзел у абмеркаванні карыстальніка" + "translation": "Немагчыма атрымаць прыналежнасць карыстальніка да гілкі." }, { "id": "app.user.get_recently_active_users.app_error", - "translation": "Адбылася памылка пры пошуку нядаўна актыўных карыстальнікаў." + "translation": "Мы сутыкнуліся з памылкай пры пошуку нядаўна актыўных карыстальнікаў." }, { "id": "app.user.get_profiles.app_error", - "translation": "Узнікла памылка пры пошуку карыстацкіх профіляў." + "translation": "Мы сутыкнуліся з памылкай пры пошуку профіляў карыстальнікаў." }, { "id": "app.user.get_profile_by_group_channel_ids_for_user.app_error", - "translation": "Узнікла памылка пры пошуку карыстацкіх профіляў." + "translation": "Мы сутыкнуліся з памылкай пры пошуку профіляў карыстальнікаў." }, { "id": "app.user.get_new_users.app_error", - "translation": "Адбылася памылка пры пошуку новых карыстальнікаў." + "translation": "Мы сутыкнуліся з памылкай пры пошуку новых карыстальнікаў." }, { "id": "app.user.get_known_users.get_users.app_error", - "translation": "Немагчыма атрымаць вядомых карыстальнікаў з базы даных." + "translation": "Немагчыма атрымаць звесткі пра карыстальнікаў з базы дадзеных." }, { "id": "app.user.get_by_username.app_error", - "translation": "Не атрымалася знайсці сапраўдны ўліковы запіс з адпаведным імем карыстальніка для гэтай каманды. Гэтая каманда можа патрабаваць запрашэння ад уладальніка каманды для далучэння." + "translation": "Немагчыма знайсці існуючы ўліковы запіс, які адпавядае вашаму імя карыстальніка для гэтай каманды. Гэтая каманда можа патрабаваць запрашэння ад уладальніка каманды для далучэння." }, { "id": "app.user.get_by_auth.other.app_error", - "translation": "Узнікла памылка пры спробе знайсці ўліковы запіс па тыпе аўтэнтыфікацыі." + "translation": "Мы сутыкнуліся з памылкай пры спробе знайсці ўліковы запіс па тыпе аўтэнтыфікацыі." }, { "id": "app.user.get_by_auth.missing_account.app_error", - "translation": "Не атрымалася знайсці сапраўдны ўліковы запіс з адпаведным тыпам аўтэнтыфікацыі для гэтай каманды. Гэтая каманда можа патрабаваць запрашэння ад уладальніка каманды для далучэння." + "translation": "Немагчыма знайсці існуючы ўліковы запіс, які адпавядае вашаму тыпу аўтэнтыфікацыі для гэтай каманды. Гэтая каманда можа патрабаваць запрашэння ад уладальніка каманды для далучэння." }, { "id": "app.user.get.app_error", - "translation": "Узнікла памылка пошуку ўліковага запісу." + "translation": "Мы сутыкнуліся з памылкай пры пошуку ўліковага запісу." }, { "id": "app.user.demote_user_to_guest.user_update.app_error", - "translation": "Не ўдалося абнавіць карыстальніка." + "translation": "Немагчыма абнавіць карыстальніка." }, { "id": "app.user.convert_bot_to_user.app_error", - "translation": "Немагчыма пераўтварыць робата ў карыстальніка." + "translation": "Немагчыма пераўтварыць бота ў карыстальніка." }, { "id": "app.user.clear_all_custom_role_assignments.select.app_error", - "translation": "Не ўдалося атрымаць карыстальнікаў." + "translation": "Немагчыма атрымаць карыстальнікаў." }, { "id": "app.user.analytics_get_inactive_users_count.app_error", - "translation": "Мы не змаглі палічыць неактыўных карыстальнікаў." + "translation": "Мы не змаглі падлічыць неактыўных карыстальнікаў." }, { "id": "app.user.analytics_daily_active_users.app_error", - "translation": "Не атрымалася атрымаць актыўных карыстальнікаў на працягу запытанага перыяду." + "translation": "Немагчыма атрымаць актыўных карыстальнікаў за запытаны перыяд." }, { "id": "app.upload.upload_data.update.app_error", - "translation": "Не ўдалося абнавіць сесію загрузкі." + "translation": "Немагчыма абнавіць сесію загрузкі." }, { "id": "app.upload.upload_data.save.app_error", - "translation": "Не атрымалася захаваць інфармацыю аб файле." + "translation": "Немагчыма захаваць інфармацыю пра файл." }, { "id": "app.upload.upload_data.read_file.app_error", - "translation": "Не ўдалося прачытаць файл." + "translation": "Немагчыма прачытаць файл." }, { "id": "app.upload.upload_data.move_file.app_error", - "translation": "Не атрымалася перамясціць загружаны файл." + "translation": "Немагчыма перамясціць загружаны файл." }, { "id": "app.upload.upload_data.large_image.app_error", - "translation": "Памер файла {{.Filename}} ({{.Width}} на {{.Height}} пікселяў) перавышае ліміты." + "translation": "Памеры {{.Filename}} ({{.Width}} на {{.Height}} пікселяў) перавышаюць ліміты." }, { "id": "app.upload.upload_data.first_part_too_small.app_error", - "translation": "Не атрымалася загрузіць дадзеныя. Першая частка павінна быць прынамсі {{.Памер}} байт." + "translation": "Немагчыма загрузіць даныя. Першая частка павінна быць не менш за {{.Size}} байт." }, { "id": "app.upload.upload_data.concurrent.app_error", - "translation": "Не ўдалося загрузіць дадзеныя з некалькіх запытаў." + "translation": "Немагчыма загрузіць даныя з некалькіх запытаў." }, { "id": "app.upload.run_plugins_hook.rejected", - "translation": "Не атрымалася загрузіць файл {{.Filename}}. Адхілена плагінам: {{.Reason}}" + "translation": "Немагчыма загрузіць файл {{.Filename}}. Адхілена плагінам: {{.Reason}}" }, { "id": "app.upload.run_plugins_hook.move_fail", - "translation": "Не ўдалося перамясціць файл." + "translation": "Немагчыма перамясціць файл." }, { "id": "app.upload.get_for_user.app_error", - "translation": "Не ўдалося атрымаць загружанае для карыстальніка." + "translation": "Немагчыма атрымаць загрузкі для карыстальніка." }, { "id": "app.upload.get.app_error", - "translation": "Не ўдалося атрымаць загрузкі." + "translation": "Немагчыма атрымаць загрузку." }, { "id": "app.upload.create.save.app_error", - "translation": "Не ўдалося захаваць загружанае." + "translation": "Немагчыма захаваць загрузку." }, { "id": "app.upload.create.incorrect_channel_id.app_error", - "translation": "Немагчыма загрузіць на ўказаны канал." + "translation": "Немагчыма загрузіць у пазначаны канал." }, { "id": "app.upload.create.cannot_upload_to_deleted_channel.app_error", - "translation": "Немагчыма стварыць пост у выдаленым канале." + "translation": "Немагчыма загрузіць у выдалены канал." }, { "id": "app.terms_of_service.get.no_rows.app_error", - "translation": "Умовы выкарыстання не знойдзены." + "translation": "Умовы абслугоўвання не знойдзены." }, { "id": "app.terms_of_service.get.app_error", - "translation": "Не ўдалося атрымаць Умовы выкарыстання." + "translation": "Немагчыма атрымаць умовы абслугоўвання." }, { "id": "app.terms_of_service.create.existing.app_error", - "translation": "Не павінен выклікаць \"захаванне\" для дзейных Умоў выкарыстання." + "translation": "Нельга выклікаць захаванне для існуючых умоў абслугоўвання." }, { "id": "app.terms_of_service.create.app_error", - "translation": "Не ўдалося захаваць умовы выкарыстання." + "translation": "Немагчыма захаваць умовы абслугоўвання." }, { "id": "app.team.user_belongs_to_teams.app_error", - "translation": "Немагчыма вызначыць, ці належыць карыстач да спісу каманд." + "translation": "Немагчыма вызначыць, ці належыць карыстальнік да спісу каманд." }, { "id": "app.team.update.updating.app_error", - "translation": "Узнікла памылка абнаўлення каманды." + "translation": "Мы сутыкнуліся з памылкай пры абнаўленні каманды." }, { "id": "app.team.update.find.app_error", - "translation": "Не ўдалося знайсці існуючую каманду для абнаўлення." + "translation": "Немагчыма знайсці існуючую каманду для абнаўлення." }, { "id": "app.team.search_private_team.app_error", - "translation": "Узнікла праблема пры пошуку прыватных каманд." + "translation": "Мы сутыкнуліся з памылкай пры пошуку прыватных каманд." }, { "id": "app.team.search_open_team.app_error", - "translation": "Узнікла праблема пры пошуку адкрытых каманд." + "translation": "Мы сутыкнуліся з памылкай пры пошуку адкрытых каманд." }, { "id": "app.team.search_all_team.app_error", - "translation": "Узнікла праблема пры пошуку каманд." + "translation": "Мы сутыкнуліся з памылкай пры пошуку каманд." }, { "id": "app.team.save_member.save.app_error", - "translation": "Не ўдалося захаваць удзельніка каманды." + "translation": "Немагчыма захаваць члена каманды." }, { "id": "app.team.save.existing.app_error", - "translation": "Неабходна выклікаць абнаўленне для існуючай каманды." + "translation": "Трэба выклікаць абнаўленне для існуючай каманды." }, { "id": "app.team.save.app_error", - "translation": "Не ўдалося захаваць каманду." + "translation": "Немагчыма захаваць каманду." }, { "id": "app.team.reset_all_team_schemes.app_error", @@ -3109,11 +3109,11 @@ }, { "id": "app.team.rename_team.name_occupied", - "translation": "Немагчыма перайменаваць каманду, імя ўжо выкарыстоўваецца." + "translation": "Немагчыма перайменаваць каманду, назва ўжо выкарыстоўваецца." }, { "id": "app.team.remove_member.app_error", - "translation": "Не атрымалася выдаліць удзельніка каманды." + "translation": "Немагчыма выдаліць члена каманды." }, { "id": "app.team.permanentdeleteteam.internal_error", @@ -3121,127 +3121,127 @@ }, { "id": "app.team.permanent_delete.app_error", - "translation": "Не атрымалася выдаліць існуючую каманду." + "translation": "Немагчыма выдаліць існуючую каманду." }, { "id": "app.team.migrate_team_members.update.app_error", - "translation": "Не ўдалося абнавіць удзельніка каманды." + "translation": "Немагчыма абнавіць члена каманды." }, { "id": "app.team.join_user_to_team.save_member.max_accounts.app_error", - "translation": "Немагчыма стварыць новы ўдзел у камандзе, таму што каманда дасягнула мяжы ўдзельнікаў" + "translation": "Немагчыма стварыць новае членства ў камандзе, бо каманда дасягнула ліміту ўдзельнікаў" }, { "id": "app.team.join_user_to_team.save_member.conflict.app_error", - "translation": "Немагчыма стварыць новы ўдзел у камандзе, таму што ён ужо існуе" + "translation": "Немагчыма стварыць новае членства ў камандзе, бо яно ўжо існуе" }, { "id": "app.team.join_user_to_team.save_member.app_error", - "translation": "Немагчыма стварыць новы ўдзел у камандзе" + "translation": "Немагчыма стварыць новае членства ў камандзе" }, { "id": "app.team.join_user_to_team.max_accounts.app_error", - "translation": "Гэтая каманда дасягнула максімальнай колькасці дазволеных уліковых запісаў. Звяжыцеся з сістэмным адміністратарам для павелічэння ліміту уліковых запісаў." + "translation": "Гэтая каманда дасягнула максімальнай колькасці дазволеных уліковых запісаў. Звярніцеся да вашага сістэмнага адміністратара, каб усталяваць вышэйшы ліміт." }, { "id": "app.team.invite_token.group_constrained.error", - "translation": "Немагчыма далучыцца да кіраванай групамі камандзе па токене." + "translation": "Немагчыма далучыцца да каманды з абмежаваннем групы па токене." }, { "id": "app.team.invite_id.group_constrained.error", - "translation": "Немагчыма далучыцца да кіруемай групамі камандзе па запрашэнні." + "translation": "Немагчыма далучыцца да каманды з абмежаваннем групы па запрашэнні." }, { "id": "app.team.get_user_team_ids.app_error", - "translation": "Немагчыма атрымаць спіс каманд, у якіх складаецца карыстач." + "translation": "Немагчыма атрымаць спіс каманд карыстальніка." }, { "id": "app.team.get_unread.app_error", - "translation": "Не атрымалася атрымаць спіс непрачытаных паведамленняў для каманд." + "translation": "Немагчыма атрымаць непрачытаныя паведамленні каманд." }, { "id": "app.team.get_members_by_ids.app_error", - "translation": "Не ўдалося атрымаць удзельнікаў каманды." + "translation": "Немагчыма атрымаць членаў каманды." }, { "id": "app.team.get_members.app_error", - "translation": "Не ўдалося атрымаць удзельнікаў каманды." + "translation": "Немагчыма атрымаць членаў каманды." }, { "id": "app.team.get_member_count.app_error", - "translation": "Не ўдалося падлічыць удзельнікаў каманды." + "translation": "Немагчыма падлічыць членаў каманды." }, { "id": "app.team.get_member.missing.app_error", - "translation": "Не знойдзена ніводнага ўдзельніка каманды па дадзеных ідэнтыфікатарах карыстальніка і каманды." + "translation": "Не знойдзена ніводнага члена каманды для гэтага ідэнтыфікатара карыстальніка і каманды." }, { "id": "app.team.get_member.app_error", - "translation": "Не ўдалося атрымаць удзельніка каманды." + "translation": "Немагчыма атрымаць члена каманды." }, { "id": "app.team.get_common_team_ids_for_users.app_error", - "translation": "Не атрымалася атрымаць ID каманды." + "translation": "Немагчыма атрымаць агульныя ідэнтыфікатары каманд." }, { "id": "app.team.get_by_scheme.app_error", - "translation": "Немагчыма атрымаць каналы для прадстаўленай схемы." + "translation": "Немагчыма атрымаць каналы для зададзенай схемы." }, { "id": "app.team.get_by_name.missing.app_error", - "translation": "Не ўдалося знайсці існуючую каманду." + "translation": "Немагчыма знайсці існуючую каманду." }, { "id": "app.team.get_by_name.app_error", - "translation": "Не ўдалося знайсці існуючую каманду." + "translation": "Немагчыма знайсці існуючую каманду." }, { "id": "app.team.get_by_invite_id.finding.app_error", - "translation": "Не ўдалося знайсці існуючую каманду." + "translation": "Немагчыма знайсці існуючую каманду." }, { "id": "app.team.get_all_team_listing.app_error", - "translation": "Не ўдалося атрымаць усе каманды." + "translation": "Мы не змаглі атрымаць усе каманды." }, { "id": "app.team.get_all_private_team_listing.app_error", - "translation": "Не ўдалося атрымаць усе прыватныя каманды." + "translation": "Мы не змаглі атрымаць усе прыватныя каманды." }, { "id": "app.team.get_all.app_error", - "translation": "Не ўдалося атрымаць усе каманды." + "translation": "Мы не змаглі атрымаць усе каманды." }, { "id": "app.team.get_active_member_count.app_error", - "translation": "Не ўдалося падлічыць удзельнікаў каманды." + "translation": "Немагчыма падлічыць членаў каманды." }, { "id": "app.team.get.finding.app_error", - "translation": "Узнікла памылка з выяўленнем каманды." + "translation": "Мы сутыкнуліся з памылкай пры пошуку каманды." }, { "id": "app.team.get.find.app_error", - "translation": "Не ўдалося знайсці існуючую каманду." + "translation": "Немагчыма знайсці існуючую каманду." }, { "id": "app.team.clear_all_custom_role_assignments.select.app_error", - "translation": "Не ўдалося атрымаць удзельнікаў каманды." + "translation": "Немагчыма атрымаць членаў каманды." }, { "id": "app.team.analytics_team_count.app_error", - "translation": "Не ўдалося падлічыць каманды." + "translation": "Немагчыма падлічыць каманды." }, { "id": "app.system_install_date.parse_int.app_error", - "translation": "Не ўдалося разабраць дату ўстаноўкі." + "translation": "Немагчыма разабраць дату ўстаноўкі." }, { "id": "app.system.save.app_error", - "translation": "Узнікла памылка пры захаванні ўласцівасці сістэмы." + "translation": "Мы сутыкнуліся з памылкай пры захаванні ўласцівасці сістэмы." }, { "id": "app.system.permanent_delete_by_name.app_error", - "translation": "Мы не можам канчаткова выдаліць запіс сістэмнай табліцы." + "translation": "Мы не змаглі назаўсёды выдаліць запіс сістэмнай табліцы." }, { "id": "app.system.get_by_name.app_error", @@ -3249,15 +3249,15 @@ }, { "id": "app.submit_interactive_dialog.json_error", - "translation": "Выяўлена памылка кадавання JSON для інтэрактыўнага дыялогу." + "translation": "Сутыкнуліся з памылкай кадавання JSON для інтэрактыўнага дыялогу." }, { "id": "app.status.get.missing.app_error", - "translation": "Не існуе запісу для гэтага статусу." + "translation": "Няма запісу для гэтага статусу." }, { "id": "app.status.get.app_error", - "translation": "Узнікла памылка пры атрыманні статуту." + "translation": "Сутыкнуліся з памылкай пры атрыманні статусу." }, { "id": "app.session.update_device_id.app_error", @@ -3281,15 +3281,15 @@ }, { "id": "app.session.permanent_delete_sessions_by_user.app_error", - "translation": "Немагчыма выдаліць усе сеансы для карыстальніка." + "translation": "Немагчыма выдаліць усе сесіі для карыстальніка." }, { "id": "app.session.get_sessions.app_error", - "translation": "Мы выявілі памылку пры пошуку карыстацкіх сесій." + "translation": "Мы сутыкнуліся з памылкай пры пошуку сесій карыстальнікаў." }, { "id": "app.session.get.app_error", - "translation": "Мы знайшлі памылку пры пошуку сесіі." + "translation": "Мы сутыкнуліся з памылкай пры пошуку сесіі." }, { "id": "app.session.analytics_session_count.app_error", @@ -3297,15 +3297,15 @@ }, { "id": "app.schemes.is_phase_2_migration_completed.not_completed.app_error", - "translation": "Гэтая канчатковая кропка API недаступная, паколькі неабходныя міграцыі яшчэ не завершаны." + "translation": "Гэты пункт прызначэння API недаступны, бо неабходныя міграцыі яшчэ не завершаны." }, { "id": "app.scheme.save.invalid_scheme.app_error", - "translation": "Указаная схема несапраўдная." + "translation": "Зададзеная схема несапраўдная." }, { "id": "app.scheme.save.app_error", - "translation": "Не ўдалося стварыць схему." + "translation": "Немагчыма стварыць схему." }, { "id": "app.scheme.permanent_delete_all.app_error", @@ -3313,19 +3313,19 @@ }, { "id": "app.scheme.get.app_error", - "translation": "Не ўдалося атрымаць схему." + "translation": "Немагчыма атрымаць схему." }, { "id": "app.scheme.delete.app_error", - "translation": "Немагчыма выдаліць гэтую схему." + "translation": "Немагчыма выдаліць гэту схему." }, { "id": "app.save_config.app_error", - "translation": "Адбылася памылка пры захаванні канфігурацыі." + "translation": "Памылка пры захаванні канфігурацыі." }, { "id": "app.role.save.invalid_role.app_error", - "translation": "Роля не была сапраўднай." + "translation": "Роля была несапраўднай." }, { "id": "app.role.save.insert.app_error", @@ -3353,51 +3353,51 @@ }, { "id": "app.recover.save.app_error", - "translation": "Не ўдалося захаваць токен." + "translation": "Немагчыма захаваць токен." }, { "id": "app.recover.delete.app_error", - "translation": "Не атрымалася выдаліць токен." + "translation": "Немагчыма выдаліць токен." }, { "id": "app.reaction.save.save.app_error", - "translation": "Не ўдалося захаваць рэакцыю." + "translation": "Немагчыма захаваць рэакцыю." }, { "id": "app.reaction.get_for_post.app_error", - "translation": "Не атрымалася атрымаць рэакцыі для дадзенага паведамлення." + "translation": "Немагчыма атрымаць рэакцыі на паведамленне." }, { "id": "app.reaction.delete_all_with_emoji_name.get_reactions.app_error", - "translation": "Не атрымалася атрымаць усе рэакцыі з дадзеным імем смайла." + "translation": "Немагчыма атрымаць усе рэакцыі з такой назвай эмодзі." }, { "id": "app.reaction.bulk_get_for_post_ids.app_error", - "translation": "Не атрымалася атрымаць рэакцыі для дадзенага паведамлення." + "translation": "Немагчыма атрымаць рэакцыі на паведамленне." }, { "id": "app.preference.save.updating.app_error", - "translation": "Узнікла памылка пры абнаўленні налад." + "translation": "Мы сутыкнуліся з памылкай пры абнаўленні налад." }, { "id": "app.preference.permanent_delete_by_user.app_error", - "translation": "Узнікла памылка пры выдаленні налад." + "translation": "Мы сутыкнуліся з памылкай пры выдаленні налад." }, { "id": "app.preference.get_category.app_error", - "translation": "Узнікла памылка пры пошуку настроек." + "translation": "Мы сутыкнуліся з памылкай пры пошуку налад." }, { "id": "app.preference.get_all.app_error", - "translation": "Узнікла памылка пры пошуку настроек." + "translation": "Мы сутыкнуліся з памылкай пры пошуку налад." }, { "id": "app.preference.get.app_error", - "translation": "Узнікла памылка пры пошуку настроек." + "translation": "Мы сутыкнуліся з памылкай пры пошуку налад." }, { "id": "app.preference.delete.app_error", - "translation": "Узнікла памылка пры выдаленні налад." + "translation": "Мы сутыкнуліся з памылкай пры выдаленні налад." }, { "id": "app.post.update.app_error", @@ -3405,7 +3405,7 @@ }, { "id": "app.post.save.existing.app_error", - "translation": "Вы не можаце абнавіць існуючае паведамленне." + "translation": "Нельга абнавіць існуючае паведамленне." }, { "id": "app.post.save.app_error", @@ -3413,7 +3413,7 @@ }, { "id": "app.post.permanent_delete_by_user.app_error", - "translation": "Не атрымалася абраць для выдалення пасты карыстальніка." + "translation": "Немагчыма выбраць паведамленні для выдалення для карыстальніка." }, { "id": "app.post.permanent_delete_by_channel.app_error", @@ -3421,47 +3421,47 @@ }, { "id": "app.post.overwrite.app_error", - "translation": "Не ўдалося змяніць паведамленне." + "translation": "Немагчыма перазапісаць паведамленне." }, { "id": "app.post.get_root_posts.app_error", - "translation": "Не ўдалося атрымаць паведамленні для канала." + "translation": "Немагчыма атрымаць паведамленні для канала." }, { "id": "app.post.get_posts_since.app_error", - "translation": "Не ўдалося атрымаць паведамленні для канала." + "translation": "Немагчыма атрымаць паведамленні для канала." }, { "id": "app.post.get_posts_created_at.app_error", - "translation": "Не ўдалося атрымаць паведамленні для канала." + "translation": "Немагчыма атрымаць паведамленні для канала." }, { "id": "app.post.get_posts_around.get.app_error", - "translation": "Не ўдалося атрымаць паведамленні для канала." + "translation": "Немагчыма атрымаць паведамленні для канала." }, { "id": "app.post.get_posts.app_error", - "translation": "Перавышана абмежаванне колькасці старонак." + "translation": "Перавышаны ліміт пагінацыі." }, { "id": "app.post.get_post_id_around.app_error", - "translation": "Немагчыма атрымаць паведамленне на працягу вызначанага часу." + "translation": "Немагчыма атрымаць паведамленне вакол вызначанага часу." }, { "id": "app.post.get_post_after_time.app_error", - "translation": "Немагчыма атрымаць паведамленне пасля заканчэння часу." + "translation": "Немагчыма атрымаць паведамленне пасля вызначанага часу." }, { "id": "app.post.get_flagged_posts.app_error", - "translation": "Не ўдалося атрымаць адзначаныя сцягам паведамленні." + "translation": "Немагчыма атрымаць пазначаныя паведамленні." }, { "id": "app.post.get_direct_posts.app_error", - "translation": "Не ўдалося атрымаць асабістыя паведамленні." + "translation": "Немагчыма атрымаць асабістыя паведамленні." }, { "id": "app.post.get.app_error", - "translation": "Не ўдалося атрымаць паведамленне." + "translation": "Немагчыма атрымаць паведамленне." }, { "id": "app.post.delete.app_error", @@ -3481,7 +3481,7 @@ }, { "id": "app.plugin_store.save.app_error", - "translation": "Не атрымалася захаваць ці абнавіць значэнне ключа плагіна." + "translation": "Немагчыма захаваць або абнавіць значэнне ключа плагіна." }, { "id": "app.plugin_store.list.app_error", @@ -3489,63 +3489,63 @@ }, { "id": "app.plugin_store.get.app_error", - "translation": "Не атрымалася атрымаць значэнне ключа плагіна." + "translation": "Немагчыма атрымаць значэнне ключа плагіна." }, { "id": "app.plugin_store.delete.app_error", - "translation": "Не атрымалася выдаліць значэнне ключа плагіна." + "translation": "Немагчыма выдаліць значэнне ключа плагіна." }, { "id": "app.plugin.write_file.saving.app_error", - "translation": "Адбылася памылка пры захаванні файла." + "translation": "Памылка пры захаванні файла." }, { "id": "app.plugin.write_file.read.app_error", - "translation": "Адбылася памылка пры чытанні файла." + "translation": "Памылка пры чытанні файла." }, { "id": "app.plugin.webapp_bundle.app_error", - "translation": "Немагчыма згенераваць пакет для вэб-часткі плагіна (камплекта вэб-прыкладанні)." + "translation": "Немагчыма стварыць пакет вэб-прыкладання плагіна." }, { "id": "app.plugin.upload_disabled.app_error", - "translation": "Убудовы і/або загрузка плагінаў адключаныя." + "translation": "Плагіны і/або загрузкі плагінаў адключаны." }, { "id": "app.plugin.sync.read_local_folder.app_error", - "translation": "Памылка чытання лакальнай тэчкі плагінаў." + "translation": "Памылка пры чытанні лакальнай тэчкі плагінаў." }, { "id": "app.plugin.sync.list_filestore.app_error", - "translation": "Памылка чытання файлаў з тэчкі плагінаў у файлавым сховішчы." + "translation": "Памылка пры чытанні файлаў з тэчкі плагінаў у файлавым сховішчы." }, { "id": "app.plugin.store_signature.app_error", - "translation": "Не атрымалася захаваць сігнатуру плагіна ў бягучае файлавае сховішча." + "translation": "Немагчыма захаваць подпіс плагіна ў наладжаным файлавым сховішчы." }, { "id": "app.plugin.store_bundle.app_error", - "translation": "Не атрымалася захаваць плягін у бягучае файлавае сховішча." + "translation": "Немагчыма захаваць плагін у наладжаным файлавым сховішчы." }, { "id": "app.notification.body.thread.title", - "translation": "{{.SenderName}} адказаў у абмеркаванні" + "translation": "{{.SenderName}} адказаў на гілку абмеркавання" }, { "id": "app.notification.body.mention.title", - "translation": "{{.SenderName}} згадаў Вас у паведамленні" + "translation": "{{.SenderName}} згадаў вас у паведамленні" }, { "id": "app.notification.body.mention.subTitle", - "translation": "Пакуль Вас не было, {{.SenderName}} згадаў Вас у канале {{.ChannelName}}." + "translation": "Пакуль вас не было, {{.SenderName}} згадаў вас у канале {{.ChannelName}}." }, { "id": "app.notification.body.group.title", - "translation": "{{.SenderName}} даслаў Вам новае паведамленне" + "translation": "{{.SenderName}} адправіў вам новае паведамленне" }, { "id": "app.notification.body.group.subTitle", - "translation": "Пакуль вас не было, {{.SenderName}} даслаў паведамленне Вашай групе." + "translation": "Пакуль вас не было, {{.SenderName}} адправіў паведамленне вашай групе." }, { "id": "app.notification.body.dm.title", @@ -3553,39 +3553,39 @@ }, { "id": "app.notification.body.dm.subTitle", - "translation": "Пакуль вас не было, {{.SenderName}} даслаў вам новае асабістае паведамленне." + "translation": "Пакуль вас не было, {{.SenderName}} адправіў вам новае прамое паведамленне." }, { "id": "app.job.update.app_error", - "translation": "Не ўдалося абнавіць заданне." + "translation": "Немагчыма абнавіць задачу." }, { "id": "app.job.save.app_error", - "translation": "Не ўдалося захаваць заданне." + "translation": "Немагчыма захаваць задачу." }, { "id": "app.job.get_newest_job_by_status_and_type.app_error", - "translation": "Немагчыма атрымаць самае новае заданне па статуце і тыпу." + "translation": "Немагчыма атрымаць найноўшую задачу па статусе і тыпе." }, { "id": "app.job.get_count_by_status_and_type.app_error", - "translation": "Немагчыма атрымаць колькасць заданняў па статуце і тыпе." + "translation": "Немагчыма атрымаць колькасць задач па статусе і тыпе." }, { "id": "app.job.get_all.app_error", - "translation": "Не ўдалося атрымаць заданні." + "translation": "Немагчыма атрымаць задачы." }, { "id": "app.job.get.app_error", - "translation": "Не ўдалося атрымаць заданне." + "translation": "Немагчыма атрымаць задачу." }, { "id": "app.job.download_export_results_not_enabled", - "translation": "DownloadExportResults у config.json мае значэнне false. Калі ласка, усталюйце значэнне true, каб загрузіць вынікі гэтага задання." + "translation": "DownloadExportResults у config.json мае значэнне false. Калі ласка, усталюйце значэнне true, каб спампаваць вынікі гэтай задачы." }, { "id": "app.import.validate_user_teams_import_data.team_name_missing.error", - "translation": "Прапушчана імя каманды ў \"Удзел карыстальнікаў у камандзе\"." + "translation": "Назва каманды адсутнічае ў членстве карыстальніка ў камандзе." }, { "id": "app.import.validate_user_teams_import_data.invalid_team_theme.error", @@ -3593,23 +3593,23 @@ }, { "id": "app.import.validate_user_teams_import_data.invalid_roles.error", - "translation": "Няправільныя ролі ў \"Удзел карыстальнікаў у камандзе\"." + "translation": "Няправільныя ролі для членства карыстальніка ў камандзе." }, { "id": "app.import.validate_user_import_data.username_missing.error", - "translation": "Прапушчана неабходная ўласцівасць user:username." + "translation": "Адсутнічае абавязковая ўласцівасць карыстальніка: username." }, { "id": "app.import.validate_user_import_data.username_invalid.error", - "translation": "Няправільнае імя карыстальніка." + "translation": "Імя карыстальніка несапраўднае." }, { "id": "app.import.validate_user_import_data.roles_invalid.error", - "translation": "Няправільныя ролі карыстальніка." + "translation": "Ролі карыстальніка несапраўдныя." }, { "id": "app.import.validate_user_import_data.position_length.error", - "translation": "Размяшчэнне карыстальніка занадта доўгае." + "translation": "Пасада карыстальніка занадта доўгая." }, { "id": "app.import.validate_user_import_data.password_length.error", @@ -3617,7 +3617,7 @@ }, { "id": "app.import.validate_user_import_data.notify_props_mobile_push_status_invalid.error", - "translation": "Няправільная ўласцівасць Mobile Push Status Notify для карыстальніка." + "translation": "Няправільнае значэнне Mobile Push Status Notify Prop для карыстальніка." }, { "id": "app.import.validate_user_import_data.notify_props_mobile_invalid.error", @@ -3641,11 +3641,11 @@ }, { "id": "app.import.validate_user_import_data.notify_props_channel_trigger_invalid.error", - "translation": "Няверныя Channel Trigger Notify Prop для карыстальніка." + "translation": "Некарэктная ўласцівасць Channel Trigger Notify для карыстальніка." }, { "id": "app.import.validate_user_import_data.nickname_length.error", - "translation": "Псеўданім карыстальніка занадта доўгі." + "translation": "Нік карыстальніка занадта доўгі." }, { "id": "app.import.validate_user_import_data.last_name_length.error", @@ -3657,95 +3657,95 @@ }, { "id": "app.import.validate_user_import_data.email_missing.error", - "translation": "Прапушчана абавязковая ўласцівасць user: email." + "translation": "Адсутнічае абавязковая ўласцівасць карыстальніка: email." }, { "id": "app.import.validate_user_import_data.email_length.error", - "translation": "E-Mail карыстальніка мае няправільную даўжыню." + "translation": "Электронная пошта карыстальніка мае несапраўдную даўжыню." }, { "id": "app.import.validate_user_import_data.auth_data_length.error", - "translation": "Структура AuthData карыстальніка занадта вялікая." + "translation": "AuthData карыстальніка занадта доўгія." }, { "id": "app.import.validate_user_import_data.auth_data_and_service_dependency.error", - "translation": "Карыстальніцкія AuthService і AuthData з'яўляюцца ўзаемна ўключальнымі." + "translation": "AuthService карыстальніка і AuthData ўзаемазвязаныя." }, { "id": "app.import.validate_user_import_data.auth_data_and_password.error", - "translation": "Карыстальніцкія AuthData і Пароль узаемавыключальныя." + "translation": "AuthData карыстальніка і пароль узаемавыключальныя." }, { "id": "app.import.validate_user_import_data.advanced_props_show_unread_section.error", - "translation": "Няправільны параметр паказу непрачытанай часткі для карыстальніка" + "translation": "Некарэктная налада паказу раздзела з непрачытанымі паведамленнямі для карыстальніка." }, { "id": "app.import.validate_user_import_data.advanced_props_formatting.error", - "translation": "Няправільны параметр фарматавання запісу для карыстальніка" + "translation": "Некарэктная налада фарматавання паведамленняў для карыстальніка." }, { "id": "app.import.validate_user_import_data.advanced_props_feature_markdown_preview.error", - "translation": "Няправільныя параметры папярэдняга прагляду markdown для карыстальніка" + "translation": "Некарэктная налада папярэдняга прагляду markdown для карыстальніка." }, { "id": "app.import.validate_user_import_data.advanced_props_email_interval.error", - "translation": "Няправільны інтэрвал дазавання электроннай пошты для карыстальніка" + "translation": "Некарэктны інтэрвал пакетнай адпраўкі электроннай пошты для карыстальніка." }, { "id": "app.import.validate_user_channels_import_data.invalid_roles.error", - "translation": "Няправільныя ролі ў \"Удзел карыстальнікаў на каналах\"." + "translation": "Некарэктныя ролі для ўдзелу карыстальніка ў каналах." }, { "id": "app.import.validate_user_channels_import_data.invalid_notify_props_mobile.error", - "translation": "Няправільныя ўласцівасці апавяшчэння мабільных для ўдзелу карыстальніка ў каналах." + "translation": "Некарэктныя налады Mobile NotifyProps для ўдзелу карыстальніка ў каналах." }, { "id": "app.import.validate_user_channels_import_data.invalid_notify_props_mark_unread.error", - "translation": "Няправільныя ўласцівасці апавяшчэння \"прачытана\" для ўдзелу карыстальніка ў каналах." + "translation": "Некарэктныя налады MarkUnread NotifyProps для ўдзелу карыстальніка ў каналах." }, { "id": "app.import.validate_user_channels_import_data.invalid_notify_props_desktop.error", - "translation": "Няправільныя ўласцівасці апавяшчэнняў на працоўным стале для карыстацкага ўдзелу ў каналах." + "translation": "Некарэктныя налады Desktop NotifyProps для ўдзелу карыстальніка ў каналах." }, { "id": "app.import.validate_user_channels_import_data.channel_name_missing.error", - "translation": "Прапушчана імя канала ў \"Удзел карыстальнікаў у каналах\"." + "translation": "Назва канала адсутнічае ў спісе ўдзелу карыстальніка ў каналах." }, { "id": "app.import.validate_team_import_data.type_missing.error", - "translation": "Прапушчана неабходная ўласцівасць team:type." + "translation": "Адсутнічае абавязковая ўласцівасць каманды: type." }, { "id": "app.import.validate_team_import_data.type_invalid.error", - "translation": "Няправільны тып каманды." + "translation": "Тып каманды не з'яўляецца сапраўдным." }, { "id": "app.import.validate_team_import_data.scheme_invalid.error", - "translation": "Недапушчальнае імя схемы для каманды." + "translation": "Некарэктная назва схемы для каманды." }, { "id": "app.import.validate_team_import_data.name_reserved.error", - "translation": "Імя каманды змяшчае зарэзерваваныя словы." + "translation": "Назва каманды змяшчае зарэзерваваныя словы." }, { "id": "app.import.validate_team_import_data.name_missing.error", - "translation": "Прапушчана неабходная ўласцівасць team: name." + "translation": "Адсутнічае абавязковая ўласцівасць каманды: name." }, { "id": "app.import.validate_team_import_data.name_length.error", - "translation": "Імя каманды занадта доўгае." + "translation": "Назва каманды занадта доўгая." }, { "id": "app.import.validate_team_import_data.name_characters.error", - "translation": "Імя каманды змяшчае недапушчальныя знакі." + "translation": "Назва каманды змяшчае недапушчальныя сімвалы." }, { "id": "app.import.validate_team_import_data.display_name_missing.error", - "translation": "Адсутнічае патрэбнае поле для каманды: display_name." + "translation": "Адсутнічае абавязковая ўласцівасць каманды: display_name." }, { "id": "app.import.validate_team_import_data.display_name_length.error", - "translation": "Уласцівасць Team display_name не знаходзіцца ў межах дапушчальных абмежаванняў даўжыні." + "translation": "Назва каманды (display_name) не адпавядае дазволеным абмежаванням па даўжыні." }, { "id": "app.import.validate_team_import_data.description_length.error", @@ -3753,123 +3753,123 @@ }, { "id": "app.import.validate_scheme_import_data.wrong_roles_for_scope.error", - "translation": "Няправільныя ролі былі прадстаўлены для схемы з гэтай вобласцю." + "translation": "Для схемы з такім абсягам былі прадастаўлены няправільныя ролі." }, { "id": "app.import.validate_scheme_import_data.null_scope.error", - "translation": "Патрабуецца задаць вобласць ахопу Схемы." + "translation": "Патрабуецца абсяг схемы." }, { "id": "app.import.validate_scheme_import_data.display_name_invalid.error", - "translation": "Недапушчальнае імя для адлюстравання схемы." + "translation": "Некарэктная назва схемы для адлюстравання." }, { "id": "app.import.validate_role_import_data.invalid_permission.error", - "translation": "Недапушчальнае дазвол ролі." + "translation": "Некарэктны дазвол для ролі." }, { "id": "app.import.validate_role_import_data.display_name_invalid.error", - "translation": "Недапушчальнае імя для адлюстравання ролі." + "translation": "Некарэктная назва ролі для адлюстравання." }, { "id": "app.import.validate_reply_import_data.user_missing.error", - "translation": "Адсутнічае неабходнае значэнне для Reply: User." + "translation": "Адсутнічае абавязковая ўласцівасць адказу: User." }, { "id": "app.import.validate_reply_import_data.message_missing.error", - "translation": "Адсутнічае неабходнае значэнне для Reply: Message." + "translation": "Адсутнічае абавязковая ўласцівасць адказу: Message." }, { "id": "app.import.validate_reply_import_data.message_length.error", - "translation": "Значэнне Message у Reply даўжэй максімальна дазволены даўжыні." + "translation": "Уласцівасць адказу Message даўжэйшая за максімальна дазволеную даўжыню." }, { "id": "app.import.validate_reply_import_data.create_at_zero.error", - "translation": "Значэнне CreateAt у Reply павінна быць не нулявым." + "translation": "Уласцівасць адказу create_at не можа быць нулем." }, { "id": "app.import.validate_reply_import_data.create_at_missing.error", - "translation": "Адсутнічае неабходнае значэнне для Reply: create_at." + "translation": "Адсутнічае абавязковая ўласцівасць адказу: create_at." }, { "id": "app.import.validate_reaction_import_data.user_missing.error", - "translation": "Адсутнічае неабходнае значэнне для Reaction: User." + "translation": "Адсутнічае абавязковая ўласцівасць рэакцыі: User." }, { "id": "app.import.validate_reaction_import_data.emoji_name_missing.error", - "translation": "Адсутнічае неабходнае значэнне для Reaction: EmojiName." + "translation": "Адсутнічае абавязковая ўласцівасць рэакцыі: EmojiName." }, { "id": "app.import.validate_reaction_import_data.emoji_name_length.error", - "translation": "Значэнне EmojiName у Reaction даўжэй максімальна дазволенай даўжыні." + "translation": "Уласцівасць рэакцыі EmojiName даўжэйшая за максімальна дазволеную даўжыню." }, { "id": "app.import.validate_reaction_import_data.create_at_zero.error", - "translation": "Значэнне CreateAt у Reaction павінна быць не нулявым." + "translation": "Уласцівасць рэакцыі create_at не можа быць нулем." }, { "id": "app.import.validate_reaction_import_data.create_at_missing.error", - "translation": "Адсутнічае неабходнае значэнне для Reaction: create_at." + "translation": "Адсутнічае абавязковая ўласцівасць рэакцыі: create_at." }, { "id": "app.import.validate_reaction_import_data.create_at_before_parent.error", - "translation": "Значэнне CreateAt у Reaction павінна быць больш чым бацькоўскі CreateAt у Post." + "translation": "Уласцівасць create_at рэакцыі павінна быць большай за create_at бацькоўскага паведамлення." }, { "id": "app.import.validate_post_import_data.user_missing.error", - "translation": "Адсутнічае неабходнае поле для Post:User." + "translation": "Адсутнічае абавязковая ўласцівасць паведамлення: User." }, { "id": "app.import.validate_post_import_data.team_missing.error", - "translation": "Адсутнічае неабходнае поле для Post: Team." + "translation": "Адсутнічае абавязковая ўласцівасць паведамлення: Team." }, { "id": "app.import.validate_post_import_data.props_too_large.error", - "translation": "Уласцівасці паведамлення даўжэй максімальна дапушчальнай даўжыні." + "translation": "Уласцівасці паведамлення (Props) даўжэйшыя за максімальна дазволеную даўжыню." }, { "id": "app.import.validate_post_import_data.message_missing.error", - "translation": "Адсутнічае неабходнае поле для Post: Message." + "translation": "Адсутнічае абавязковая ўласцівасць паведамлення: Message." }, { "id": "app.import.validate_post_import_data.message_length.error", - "translation": "Post Message даўжэйшая, чым максімальна дазволеная даўжыня." + "translation": "Уласцівасць паведамлення Message даўжэйшая за максімальна дазволеную даўжыню." }, { "id": "app.import.validate_post_import_data.create_at_zero.error", - "translation": "Значэнне CreateAt у Post павінна быць не нулявым." + "translation": "Уласцівасць паведамлення create_at не можа быць нулем." }, { "id": "app.import.validate_post_import_data.create_at_missing.error", - "translation": "Адсутнічае патрэбнае поле для Post: create_at." + "translation": "Адсутнічае абавязковая ўласцівасць паведамлення: create_at." }, { "id": "app.import.validate_post_import_data.channel_missing.error", - "translation": "Адсутнічае неабходнае поле для Post: Channel." + "translation": "Адсутнічае абавязковая ўласцівасць паведамлення: Канал." }, { "id": "app.import.validate_emoji_import_data.name_missing.error", - "translation": "Поле \"імя імпартуемага смайліка\" прапушчана або пустое." + "translation": "Імпарт эмодзі: поле з назвай адсутнічае або пусты." }, { "id": "app.import.validate_emoji_import_data.image_missing.error", - "translation": "Імпарт emoji - выява адсутнічае або поле пустое." + "translation": "Імпарт эмодзі: поле з выявай адсутнічае або пусты." }, { "id": "app.import.validate_emoji_import_data.empty.error", - "translation": "Няма дадзеных аб эмоджы для імпарту." + "translation": "Імпарт эмодзі: дадзеныя пустыя." }, { "id": "app.import.validate_direct_post_import_data.user_missing.error", - "translation": "Адсутнічае неабходнае поле для post:" + "translation": "Прапушчана абавязковая ўласцівасць прыватнага паведамлення: user" }, { "id": "app.import.validate_direct_post_import_data.unknown_flagger.error", - "translation": "Толькі ўдзельнікі канала могуць пазначаць сцягамі прамое паведамленне ў гэтым канале. \"{{.Username}}\" не ўдзельнік." + "translation": "Прыватнае паведамленне можа быць пазначана толькі ўдзельнікамі канала, у якім яно знаходзіцца. \"{{.Username}}\" не з'яўляецца ўдзельнікам." }, { "id": "app.import.validate_direct_post_import_data.message_missing.error", - "translation": "Адсутнічае неабходнае поле для post: message" + "translation": "Прапушчана абавязковая ўласцівасць прыватнага паведамлення: message" }, { "id": "app.import.validate_direct_post_import_data.message_length.error", @@ -3877,207 +3877,207 @@ }, { "id": "app.import.validate_direct_post_import_data.create_at_zero.error", - "translation": "CreateAt павінна быць больш" + "translation": "CreateAt павінна быць больш за 0" }, { "id": "app.import.validate_direct_post_import_data.create_at_missing.error", - "translation": "Адсутнічае патрэбнае поле для post: create_at" + "translation": "Прапушчана абавязковая ўласцівасць прыватнага паведамлення: create_at" }, { "id": "app.import.validate_direct_post_import_data.channel_members_too_many.error", - "translation": "У спісе ўдзельнікаў асабістага канала занадта шмат элементаў" + "translation": "Спіс удзельнікаў прыватнага канала змяшчае занадта шмат элементаў" }, { "id": "app.import.validate_direct_post_import_data.channel_members_too_few.error", - "translation": "У спісе ўдзельнікаў асабістага канала занадта мала элементаў" + "translation": "Спіс удзельнікаў прыватнага канала змяшчае занадта мала элементаў" }, { "id": "app.import.validate_direct_post_import_data.channel_members_required.error", - "translation": "Адсутнічае патрэбнае поле для post: channel_members" + "translation": "Прапушчана абавязковая ўласцівасць прыватнага паведамлення: channel_members" }, { "id": "app.import.validate_direct_channel_import_data.unknown_favoriter.error", - "translation": "Бягучы канал можа быць дададзены да выбранага толькі ўдзельнікамі каманды. \"{{.Username}}\" не з'яўляецца ўдзельнікам гэтай каманды." + "translation": "Прыватны канал можа быць дададзены ў выбранае толькі ўдзельнікамі. \"{{.Username}}\" не з'яўляецца ўдзельнікам." }, { "id": "app.import.validate_direct_channel_import_data.members_too_many.error", - "translation": "У спісе ўдзельнікаў асабістага канала занадта шмат элементаў" + "translation": "Спіс удзельнікаў прыватнага канала змяшчае занадта шмат элементаў" }, { "id": "app.import.validate_direct_channel_import_data.members_too_few.error", - "translation": "У спісе ўдзельнікаў асабістага канала занадта мала элементаў" + "translation": "Спіс удзельнікаў прыватнага канала змяшчае занадта мала элементаў" }, { "id": "app.import.validate_direct_channel_import_data.members_required.error", - "translation": "Прапушчана абавязковая ўласцівасць direct channel: members" + "translation": "Прапушчана абавязковая ўласцівасць прыватнага канала: members" }, { "id": "app.import.validate_direct_channel_import_data.header_length.error", - "translation": "Загаловак асабістага канала занадта доўгі" + "translation": "Загаловак прыватнага канала занадта доўгі" }, { "id": "app.import.validate_channel_import_data.type_missing.error", - "translation": "Прапушчана абавязковая ўласцівасць channel:type." + "translation": "Прапушчана абавязковая ўласцівасць канала: type." }, { "id": "app.import.validate_channel_import_data.type_invalid.error", - "translation": "Няправільны тып канала." + "translation": "Тып канала несапраўдны." }, { "id": "app.import.validate_channel_import_data.team_missing.error", - "translation": "Прапушчана абавязковая ўласцівасць channel: team" + "translation": "Прапушчана абавязковая ўласцівасць канала: team" }, { "id": "app.import.validate_channel_import_data.scheme_invalid.error", - "translation": "Недапушчальнае імя схемы для канала." + "translation": "Няправільная назва схемы для канала." }, { "id": "app.import.validate_channel_import_data.purpose_length.error", - "translation": "Загаловак канала занадта доўгі." + "translation": "Мэта канала занадта доўгая." }, { "id": "app.import.validate_channel_import_data.name_missing.error", - "translation": "Прапушчана абавязковая ўласцівасць channel: name" + "translation": "Прапушчана абавязковая ўласцівасць канала: name" }, { "id": "app.import.validate_channel_import_data.name_length.error", - "translation": "Імя канала занадта доўгае." + "translation": "Назва канала занадта доўгая." }, { "id": "app.import.validate_channel_import_data.name_characters.error", - "translation": "Імя канала змяшчае недапушчальныя сімвалы." + "translation": "Назва канала змяшчае недапушчальныя сімвалы." }, { "id": "app.import.validate_channel_import_data.header_length.error", - "translation": "Даўжыня загалоўка канала занадта вялікая." + "translation": "Загаловак канала занадта доўгі." }, { "id": "app.import.validate_channel_import_data.display_name_length.error", - "translation": "Уласцівасць Channel display_name не знаходзіцца ў межах дапушчальных абмежаванняў даўжыні." + "translation": "Назва канала display_name не адпавядае дазволеным абмежаванням па даўжыні." }, { "id": "app.import.process_import_data_file_version_line.invalid_version.error", - "translation": "Немагчыма прачытаць версію імпартнага файла дадзеных." + "translation": "Немагчыма прачытаць версію файла імпарту даных." }, { "id": "app.import.import_user_teams.save_preferences.error", - "translation": "Не атрымалася захаваць настройкі тэмы каманды" + "translation": "Немагчыма захаваць налады тэмы каманды." }, { "id": "app.import.import_user_teams.save_members.max_accounts.app_error", - "translation": "Немагчыма імпартаваць удзел у камандзе, таму што ў гэтай камандзе не дазволены дадатковыя ўдзельнікі" + "translation": "Немагчыма імпартаваць членства ў камандзе, бо больш удзельнікаў у гэтай камандзе не дазволена" }, { "id": "app.import.import_user_teams.save_members.error", - "translation": "Немагчыма імпартаваць удзел у камандзе" + "translation": "Немагчыма імпартаваць членства ў камандзе" }, { "id": "app.import.import_user_teams.save_members.conflict.app_error", - "translation": "Немагчыма імпартаваць новы ўдзел у камандзе, таму што ён ужо існуе" + "translation": "Немагчыма імпартаваць новае членства ў камандзе, бо яно ўжо існуе" }, { "id": "app.import.import_user_channels.save_preferences.error", - "translation": "Памылка імпарту ўдзелу карыстальніка ў каналах. Не ўдалося захаваць наладкі." + "translation": "Памылка імпарту членства карыстальніка ў каналах. Не атрымалася захаваць налады." }, { "id": "app.import.import_user_channels.channel_not_found.error", - "translation": "Памылка імпарту карыстацкіх каналаў. Канал не знойдзены." + "translation": "Памылка імпарту каналаў карыстальніка. Канал не знойдзены." }, { "id": "app.import.import_user.save_preferences.error", - "translation": "Памылка імпарту налад карыстальніка. Не ўдалося захаваць наладкі." + "translation": "Памылка імпарту налад карыстальніка. Не атрымалася захаваць налады." }, { "id": "app.import.import_team.scheme_wrong_scope.error", - "translation": "Каманда павінна быць прызначаная схеме ўзроўня каманд." + "translation": "Каманда павінна быць прызначана схеме з абмежаваннем па камандзе." }, { "id": "app.import.import_team.scheme_deleted.error", - "translation": "Немагчыма ўсталяваць выкарыстанне выдаленай схемы для каманды." + "translation": "Немагчыма прызначыць камандзе выкарыстанне выдаленай схемы." }, { "id": "app.import.import_scheme.scope_change.error", - "translation": "Масавы імпарцёр не можа змяніць вобласць дзеяння ўжо існай схемы." + "translation": "Масавы імпарцёр не можа змяніць сферу прымянення ўжо існуючай схемы." }, { "id": "app.import.import_post.user_not_found.error", - "translation": "Памылка імпарту паведамлення. Карыстальніка з імем \"{{.Username}}\" не знойдзены." + "translation": "Памылка імпарту паведамлення. Карыстальнік з імем \"{{.Username}}\" не знойдзены." }, { "id": "app.import.import_post.save_preferences.error", - "translation": "Памылка падчас імпарту паста. Не ўдалося захаваць наладкі." + "translation": "Памылка пры імпарце паведамлення. Не атрымалася захаваць налады." }, { "id": "app.import.import_post.channel_not_found.error", - "translation": "Памылка імпарту паведамлення. Канал з імем \"{{.ChannelName}}\" не знойдзены." + "translation": "Памылка імпарту паведамлення. Канал з назвай \"{{.ChannelName}}\" не знойдзены." }, { "id": "app.import.import_line.unknown_line_type.error", - "translation": "Радок імпарту дадзеных утрымоўвае невядомы тып \"{{.Type}}\"." + "translation": "Радок даных імпарту мае невядомы тып \"{{.Type}}\"." }, { "id": "app.import.import_line.null_user.error", - "translation": "Радок імпарту дадзеных утрымоўвае тып \"user\", але аб'ект user роўны null." + "translation": "Радок даных імпарту мае тып \"user\", але аб'ект user роўны null." }, { "id": "app.import.import_line.null_team.error", - "translation": "Радок імпарту дадзеных утрымоўвае тып \"team\", але аб'ект team роўны null." + "translation": "Радок даных імпарту мае тып \"team\", але аб'ект team роўны null." }, { "id": "app.import.import_line.null_scheme.error", - "translation": "Радок імпарту дадзеных утрымоўвае тып \"scheme\", але аб'ект scheme роўны null." + "translation": "Радок даных імпарту мае тып \"scheme\", але аб'ект scheme роўны null." }, { "id": "app.import.import_line.null_post.error", - "translation": "Радок імпарту дадзеных утрымоўвае тып \"post\", але аб'ект post роўны null." + "translation": "Радок даных імпарту мае тып \"post\", але аб'ект post роўны null." }, { "id": "app.import.import_line.null_emoji.error", - "translation": "Радок імпарту дадзеных утрымоўвае тып \"emoji\", але аб'ект post роўны null." + "translation": "Радок даных імпарту мае тып \"emoji\", але аб'ект emoji роўны null." }, { "id": "app.import.import_line.null_direct_post.error", - "translation": "Радок імпарту дадзеных утрымоўвае тып \"direct_post\", але аб'ект direct_post роўны null." + "translation": "Радок даных імпарту мае тып \"direct_post\", але аб'ект direct_post роўны null." }, { "id": "app.import.import_line.null_direct_channel.error", - "translation": "Радок імпарту дадзеных утрымоўвае тып \"direct_channel\", але direct_channel з'яўляецца null." + "translation": "Радок даных імпарту мае тып \"direct_channel\", але аб'ект direct_channel роўны null." }, { "id": "app.import.import_line.null_channel.error", - "translation": "Радок імпарту дадзеных утрымоўвае тып \"channel\", але аб'ект channel роўны null." + "translation": "Радок даных імпарту мае тып \"channel\", але аб'ект channel роўны null." }, { "id": "app.import.import_direct_post.create_group_channel.error", - "translation": "Не ўдалося атрымаць групавы канал" + "translation": "Не атрымалася атрымаць групавы канал" }, { "id": "app.import.import_direct_post.create_direct_channel.error", - "translation": "Не ўдалося атрымаць прамы канал" + "translation": "Не атрымалася атрымаць прыватны канал" }, { "id": "app.import.import_direct_channel.update_header_failed.error", - "translation": "Не атрымалася абнавіць загаловак канала" + "translation": "Не атрымалася абнавіць загаловак прыватнага канала" }, { "id": "app.import.import_direct_channel.create_group_channel.error", - "translation": "Не ўдалося стварыць групавы канал" + "translation": "Не атрымалася стварыць групавы канал" }, { "id": "app.import.import_direct_channel.create_direct_channel.error", - "translation": "Не ўдалося стварыць прамы канал" + "translation": "Не атрымалася стварыць прыватны канал" }, { "id": "app.import.import_channel.team_not_found.error", - "translation": "Памылка імпарту канала. Каманда з імем \"{{.TeamName}}\" не знойдзена." + "translation": "Памылка імпарту канала. Каманда з назвай \"{{.TeamName}}\" не знойдзена." }, { "id": "app.import.import_channel.scheme_wrong_scope.error", - "translation": "Канал павінен быць прызначаны ў схеме ўзроўня канала." + "translation": "Канал павінен быць прызначаны схеме, абмежаванай каналам." }, { "id": "app.import.import_channel.scheme_deleted.error", - "translation": "Нельга прызначыць каналу выдаленую схему." + "translation": "Немагчыма прызначыць каналу выдаленую схему." }, { "id": "app.import.get_users_by_username.some_users_not_found.error", @@ -4089,31 +4089,31 @@ }, { "id": "app.import.emoji.bad_file.error", - "translation": "Памылка пры чытанні файла выявы emoji. Emoji з імем: \"{{.EmojiName}}\"" + "translation": "Памылка чытання файла выявы эмодзі. Эмодзі з назвай: \"{{.EmojiName}}\"" }, { "id": "app.import.bulk_import.unsupported_version.error", - "translation": "Няправільная ці адсутная версія ў файле імпарту дадзеных. Упэўніцеся, што версія - гэта першы аб'ект у файле імпарту і паспрабуйце зноў." + "translation": "Няправільная або адсутная версія ў файле імпарту даных. Пераканайцеся, што версія з'яўляецца першым аб'ектам у вашым файле імпарту, і паспрабуйце зноў." }, { "id": "app.import.bulk_import.json_decode.error", - "translation": "Памылка апрацоўкі радка JSON." + "translation": "Немагчыма дэкадаваць радок JSON." }, { "id": "app.import.bulk_import.file_scan.error", - "translation": "Памылка чытання імпартнага файла." + "translation": "Памылка чытання файла даных імпарту." }, { "id": "app.import.attachment.file_upload.error", - "translation": "Памылка пры загрузцы файла: \"{{.FilePath}}\"" + "translation": "Памылка загрузкі файла: \"{{.FilePath}}\"" }, { "id": "app.import.attachment.bad_file.error", - "translation": "Памылка чытання файла: \"{{.FilePath}}\"" + "translation": "Памылка чытання файла па адрасе: \"{{.FilePath}}\"" }, { "id": "app.group.uniqueness_error", - "translation": "удзельнік групы ўжо існуе" + "translation": "Удзельнік групы ўжо існуе" }, { "id": "app.group.permanent_delete_members_by_user.app_error", @@ -4121,19 +4121,19 @@ }, { "id": "app.group.no_rows", - "translation": "не знойдзена прыдатнай групы" + "translation": "Адпаведная група не знойдзена" }, { "id": "app.group.id.app_error", - "translation": "недапушчальная ўласцівасць Id для групы." + "translation": "Некарэктная ўласцівасць id для групы." }, { "id": "app.group.group_syncable_already_deleted", - "translation": "сінхранізаваная група ўжо выдалена" + "translation": "Сінхранізаваная група ўжо выдалена" }, { "id": "app.file_info.save.app_error", - "translation": "Не атрымалася захаваць інфармацыю аб файле." + "translation": "Немагчыма захаваць інфармацыю пра файл." }, { "id": "app.file_info.permanent_delete_by_user.app_error", @@ -4141,19 +4141,19 @@ }, { "id": "app.file_info.get_with_options.app_error", - "translation": "Не атрымалася атрымаць інфармацыю аб файле з наладамі" + "translation": "Немагчыма атрымаць інфармацыю пра файл з параметрамі" }, { "id": "app.file_info.get_for_post.app_error", - "translation": "Не атрымалася атрымаць інфармацыю аб файле да паведамлення." + "translation": "Немагчыма атрымаць інфармацыю пра файл для паведамлення." }, { "id": "ent.ldap.syncronize.get_all.app_error", - "translation": "Не ўдалося атрымаць усіх карыстальнікаў з дапамогай AD/LDAP." + "translation": "Немагчыма атрымаць усіх карыстальнікаў з дапамогай AD/LDAP." }, { "id": "ent.ldap.save_user.username_exists.ldap_app_error", - "translation": "Уліковы запіс з такім імем карыстальніка ўжо існуе. Калі ласка, звяжыцеся з Адміністратарам." + "translation": "Уліковы запіс з такім імем карыстальніка ўжо існуе. Калі ласка, звярніцеся да вашага адміністратара." }, { "id": "ent.ldap.save_user.email_exists.ldap_app_error", @@ -4161,7 +4161,7 @@ }, { "id": "ent.ldap.no.users.checkcertificate", - "translation": "Карыстальнікі LDAP не знойдзены, праверце карыстацкі фільтр і сертыфікаты." + "translation": "Карыстальнікі LDAP не знойдзены, праверце ваш фільтр карыстальнікаў і сертыфікаты." }, { "id": "ent.ldap.do_login.x509.app_error", @@ -4173,39 +4173,39 @@ }, { "id": "ent.ldap.do_login.user_filtered.app_error", - "translation": "Ваш уліковы запіс AD/LDAP не мае дазволу на выкарыстанне гэтага сервера Mattermost. Калі ласка, звярніцеся да сістэмнага адміністратара, каб праверыць фільтр карыстальнікаў AD/LDAP." + "translation": "Ваш уліковы запіс AD/LDAP не мае дазволу на выкарыстанне гэтага сервера Mattermost. Калі ласка, папрасіце вашага сістэмнага адміністратара праверыць фільтр карыстальнікаў AD/LDAP." }, { "id": "ent.ldap.do_login.unable_to_connect.app_error", - "translation": "Немагчыма падлучыцца да сервера AD/LDAP." + "translation": "Немагчыма падключыцца да сервера AD/LDAP." }, { "id": "ent.ldap.do_login.search_ldap_server.app_error", - "translation": "Не ўдалося знайсці сервер AD/LDAP." + "translation": "Немагчыма знайсці сервер AD/LDAP." }, { "id": "ent.ldap.do_login.matched_to_many_users.app_error", - "translation": "Дадзенае імя карыстальніка адпавядае некалькім карыстальнікам." + "translation": "Імя карыстальніка супадае з некалькімі карыстальнікамі." }, { "id": "ent.ldap.do_login.licence_disable.app_error", - "translation": "Функцыі AD/LDAP адключаныя ў выглядзе ліцэнзійных абмежаванняў. Калі ласка, звярніцеся да сістэмнага адміністратара для абнаўлення ліцэнзіі." + "translation": "Функцыянальнасць AD/LDAP адключана бягучай ліцэнзіяй. Калі ласка, звярніцеся да вашага сістэмнага адміністратара аб абнаўленні вашай карпаратыўнай ліцэнзіі." }, { "id": "ent.ldap.do_login.key.app_error", - "translation": "Памылка пры загрузцы файла ключа TLS для LDAP." + "translation": "Памылка пры загрузцы файла ключа TLS LDAP." }, { "id": "ent.ldap.do_login.certificate.app_error", - "translation": "Памылка пры загрузцы файла TLS-сертыфіката LDAP." + "translation": "Памылка загрузкі файла TLS-сертыфіката LDAP." }, { "id": "ent.ldap.do_login.bind_admin_user.app_error", - "translation": "Немагчыма зрабіць прывязку да сервера AD/LDAP. Праверце імя карыстальніка BindUsername і пароль BindPassword." + "translation": "Немагчыма падключыцца да сервера AD/LDAP. Праверце BindUsername і BindPassword." }, { "id": "ent.ldap.disabled.app_error", - "translation": "AD/LDAP адключаны ці ліцэнзія не падтрымлівае AD/LDAP." + "translation": "AD/LDAP адключаны або ліцэнзія не падтрымлівае AD/LDAP." }, { "id": "ent.ldap.create_fail", @@ -4217,215 +4217,215 @@ }, { "id": "ent.jobs.start_synchronize_job.timeout", - "translation": "Дасягнуты тайм-аўт задання сінхранізацыі AD/LDAP." + "translation": "Дасягнуты час чакання задачы сінхранізацыі AD/LDAP." }, { "id": "ent.id_loaded.license_disable.app_error", - "translation": "Ваша ліцэнзія не падтрымлівае push-паведамлення з ідэнтыфікатарам." + "translation": "Ваша ліцэнзія не падтрымлівае Push-паведамленні па ID." }, { "id": "ent.elasticsearch.test_config.reenter_password", - "translation": "URL або імя карыстальніка сервера Elasticsearch былі змененыя. Калі ласка, увядзіце Elasticsearch пароль яшчэ раз, каб праверыць злучэнне." + "translation": "URL сервера пошуку або імя карыстальніка змяніліся. Калі ласка, увядзіце пароль зноў, каб праверыць злучэнне." }, { "id": "ent.elasticsearch.test_config.license.error", - "translation": "Ваша версія сервера Mattermost не падтрымлівае Elasticsearch." + "translation": "Ваша ліцэнзія Mattermost не падтрымлівае індэксаваны пошук." }, { "id": "ent.elasticsearch.stop.already_stopped.app_error", - "translation": "Elasticsearch ужо спынены." + "translation": "{{.Backend}} ужо спынены." }, { "id": "ent.elasticsearch.start.parse_server_version.app_error", - "translation": "Не ўдалося прааналізаваць версію сервера Elasticsearch." + "translation": "Немагчыма прааналізаваць версію сервера {{.Backend}}." }, { "id": "ent.elasticsearch.start.get_server_version.app_error", - "translation": "Не ўдалося атрымаць версію сервера Elasticsearch." + "translation": "Немагчыма атрымаць версію сервера {{.Backend}}." }, { "id": "ent.elasticsearch.search_users.unmarshall_user_failed", - "translation": "Не ўдалося дэкадаваць вынікі пошуку" + "translation": "Немагчыма дэкадаваць вынікі пошуку" }, { "id": "ent.elasticsearch.search_users.search_failed", - "translation": "Пошук не ўдалося завяршыць" + "translation": "Пошук не атрымалася завяршыць" }, { "id": "ent.elasticsearch.search_posts.unmarshall_post_failed", - "translation": "Не атрымалася дэкадаваць вынікі пошуку" + "translation": "Немагчыма дэкадаваць вынікі пошуку" }, { "id": "ent.elasticsearch.search_posts.search_failed", - "translation": "Пошук не ўдалося завяршыць" + "translation": "Пошук не атрымалася завяршыць" }, { "id": "ent.elasticsearch.search_posts.parse_matches_failed", - "translation": "Не ўдалося прааналізаваць супадзенні вынікаў пошуку" + "translation": "Немагчыма прааналізаваць супадзенні вынікаў пошуку" }, { "id": "ent.elasticsearch.search_posts.disabled", - "translation": "Пошук Elasticsearch адключаны на гэтым сэрвэры" + "translation": "Пошук {{.Backend}} адключаны на гэтым серверы" }, { "id": "ent.elasticsearch.search_files.unmarshall_file_failed", - "translation": "Не атрымалася дэкадаваць вынікі пошуку" + "translation": "Немагчыма дэкадаваць вынікі пошуку" }, { "id": "ent.elasticsearch.search_files.search_failed", - "translation": "Пошук не ўдалося завяршыць" + "translation": "Пошук не атрымалася завяршыць" }, { "id": "ent.elasticsearch.search_files.disabled", - "translation": "На гэтым серверы адключаная функцыя пошуку файлаў Elasticsearch" + "translation": "Пошук файлаў {{.Backend}} адключаны на гэтым серверы" }, { "id": "ent.elasticsearch.search_channels.unmarshall_channel_failed", - "translation": "Не атрымалася дэкадаваць вынікі пошуку" + "translation": "Немагчыма дэкадаваць вынікі пошуку" }, { "id": "ent.elasticsearch.search_channels.search_failed", - "translation": "Пошук не ўдалося завяршыць" + "translation": "Пошук не атрымалася завяршыць" }, { "id": "ent.elasticsearch.search_channels.disabled", - "translation": "Пошук Elasticsearch адключаны на гэтым сэрвэры" + "translation": "Пошук {{.Backend}} адключаны на гэтым серверы" }, { "id": "ent.elasticsearch.refresh_indexes.refresh_failed", - "translation": "Не ўдалося абнавіць індэксы Elasticsearch" + "translation": "Немагчыма абнавіць пошукавыя індэксы" }, { "id": "ent.elasticsearch.post.get_posts_batch_for_indexing.error", - "translation": "Немагчыма атрымаць пакет паведамленняў для індэксацыі." + "translation": "Немагчыма атрымаць партыю паведамленняў для індэксацыі." }, { "id": "ent.elasticsearch.post.get_files_batch_for_indexing.error", - "translation": "Не ўдалося атрымаць пакет файлаў для індэксацыі." + "translation": "Немагчыма атрымаць партыю файлаў для індэксацыі." }, { "id": "ent.elasticsearch.not_started.error", - "translation": "Elasticsearch не запушчаны" + "translation": "{{.Backend}} не запушчаны" }, { "id": "ent.elasticsearch.indexer.index_batch.nothing_left_to_index.error", - "translation": "Спроба праіндэксаваць новы пакет, калі ўсе элементы былі завершаны" + "translation": "Спроба індэксаваць новую партыю, калі ўсе аб'екты завершаны" }, { "id": "ent.elasticsearch.indexer.do_job.parse_start_time.error", - "translation": "Работніку індэксавання Elasticsearch не ўдалося прааналізаваць час пачатку" + "translation": "Рабочы {{.Backend}} для індэксацыі не змог апрацаваць час пачатку" }, { "id": "ent.elasticsearch.indexer.do_job.parse_end_time.error", - "translation": "Работніку індэксавання Elasticsearch не ўдалося прааналізаваць час заканчэння" + "translation": "Рабочы {{.Backend}} для індэксацыі не змог апрацаваць час заканчэння" }, { "id": "ent.elasticsearch.indexer.do_job.get_oldest_entity.error", - "translation": "Самая старая сутнасць (карыстальнік, канал або пасада) не можа быць вынятая з базы дадзеных" + "translation": "Найстарэйшы аб'ект (карыстальнік, канал або паведамленне) не мог быць атрыманы з базы дадзеных" }, { "id": "ent.elasticsearch.index_user.error", - "translation": "Не ўдалося праіндэксаваць карыстальніка" + "translation": "Немагчыма праіндэксаваць карыстальніка" }, { "id": "ent.elasticsearch.index_post.error", - "translation": "Не ўдалося праіндэксаваць запіс" + "translation": "Немагчыма праіндэксаваць паведамленне" }, { "id": "ent.elasticsearch.index_file.error", - "translation": "Не атрымалася праіндэксаваць файл" + "translation": "Немагчыма праіндэксаваць файл" }, { "id": "ent.elasticsearch.index_channels_batch.error", - "translation": "Немагчыма атрымаць пакет каналаў для індэксацыі." + "translation": "Немагчыма атрымаць партыю каналаў для індэксацыі." }, { "id": "ent.elasticsearch.index_channel.error", - "translation": "Не ўдалося праіндэксаваць канал" + "translation": "Немагчыма праіндэксаваць канал" }, { "id": "ent.elasticsearch.generic.disabled", - "translation": "Пошук Elasticsearch не ўключаны на гэтым серверы" + "translation": "Пошук {{.Backend}} не ўключаны на гэтым серверы" }, { "id": "ent.elasticsearch.delete_user_posts.error", - "translation": "Не атрымалася выдаліць паведамленні карыстальніка" + "translation": "Немагчыма выдаліць паведамленні карыстальніка" }, { "id": "ent.elasticsearch.delete_user_files.error", - "translation": "Не атрымалася выдаліць файлы карыстальніка" + "translation": "Немагчыма выдаліць файлы карыстальніка" }, { "id": "ent.elasticsearch.delete_user.error", - "translation": "Не атрымалася выдаліць карыстальніка" + "translation": "Немагчыма выдаліць карыстальніка" }, { "id": "ent.elasticsearch.delete_post_files.error", - "translation": "Не атрымалася выдаліць файлы паведамленняў" + "translation": "Немагчыма выдаліць файлы паведамленняў" }, { "id": "ent.elasticsearch.delete_post.error", - "translation": "Не атрымалася выдаліць плягін" + "translation": "Немагчыма выдаліць паведамленне" }, { "id": "ent.elasticsearch.delete_file.error", - "translation": "Не атрымалася выдаліць файл" + "translation": "Немагчыма выдаліць файл" }, { "id": "ent.elasticsearch.delete_channel_posts.error", - "translation": "Не атрымалася выдаліць паведамленні канала" + "translation": "Немагчыма выдаліць паведамленні канала" }, { "id": "ent.elasticsearch.delete_channel.error", - "translation": "Не атрымалася выдаліць канал" + "translation": "Немагчыма выдаліць канал" }, { "id": "ent.elasticsearch.data_retention_delete_indexes.get_indexes.error", - "translation": "Не ўдалося атрымаць індэксы Elasticsearch" + "translation": "Немагчыма атрымаць індэксы {{.Backend}}" }, { "id": "ent.elasticsearch.data_retention_delete_indexes.delete_index.error", - "translation": "Не атрымалася выдаліць азначнік Elasticsearch" + "translation": "Немагчыма выдаліць індэкс {{.Backend}}" }, { "id": "ent.elasticsearch.create_template_users_if_not_exists.template_create_failed", - "translation": "Не ўдалося стварыць шаблон Elasticsearch для карыстальнікаў" + "translation": "Немагчыма стварыць шаблон {{.Backend}} для карыстальнікаў" }, { "id": "ent.elasticsearch.create_template_posts_if_not_exists.template_create_failed", - "translation": "Не атрымалася стварыць шаблон Elasticsearch для паведамленняў" + "translation": "Немагчыма стварыць шаблон {{.Backend}} для паведамленняў" }, { "id": "ent.elasticsearch.create_template_file_info_if_not_exists.template_create_failed", - "translation": "Не ўдалося стварыць шаблон Elasticsearch для файлаў" + "translation": "Немагчыма стварыць шаблон {{.Backend}} для файлаў." }, { "id": "ent.elasticsearch.create_template_channels_if_not_exists.template_create_failed", - "translation": "Не ўдалося стварыць шаблон Elasticsearch для каналаў" + "translation": "Немагчыма стварыць шаблон {{.Backend}} для каналаў." }, { "id": "ent.elasticsearch.create_client.connect_failed", - "translation": "Памылка ўстаноўкі кліента Elasticsearch" + "translation": "Наладка кліента {{.Backend}} не ўдалася." }, { "id": "ent.elasticsearch.aggregator_worker.index_job_failed.error", - "translation": "Збой у працы зборшчыка Elasticsearch з-за збою задання індэксавання" + "translation": "Рабочы агрэгатар {{.Backend}} не ўдалося з-за збою задання індэксацыі." }, { "id": "ent.elasticsearch.aggregator_worker.get_indexes.error", - "translation": "Работніку агрэгатара Elasticsearch не ўдалося атрымаць індэксы" + "translation": "Рабочы агрэгатар {{.Backend}} не змог атрымаць індэксы." }, { "id": "ent.elasticsearch.aggregator_worker.delete_indexes.error", - "translation": "Работніку агрэгатара Elasticsearch не ўдалося выдаліць індэксы" + "translation": "Рабочы агрэгатар {{.Backend}} не змог выдаліць індэксы." }, { "id": "ent.elasticsearch.aggregator_worker.create_index_job.error", - "translation": "Работніку агрэгатара Elasticsearch не ўдалося стварыць заданне індэксацыі" + "translation": "Рабочы агрэгатар {{.Backend}} не змог стварыць заданне індэксацыі." }, { "id": "ent.data_retention.run_failed.error", - "translation": "Збой задання захавання дадзеных." + "translation": "Заданне захоўвання даных не ўдалося." }, { "id": "ent.data_retention.policies.internal_error", @@ -4433,243 +4433,243 @@ }, { "id": "ent.data_retention.generic.license.error", - "translation": "Ваша ліцэнзія не падтрымлівае SAML аўтэнтыфікацыю." + "translation": "Ваша ліцэнзія не падтрымлівае захоўванне даных." }, { "id": "ent.compliance.run_failed.error", - "translation": "Экспарт камплаенс-ліста не атрымаўся для задання '{{.JobName}}' у '{{.FilePath}}'" + "translation": "Экспарт адпаведнасці не ўдаўся для задання '{{.JobName}}' у '{{.FilePath}}'." }, { "id": "ent.compliance.licence_disable.app_error", - "translation": "Функцыя камплаенсу недаступная пры бягучай ліцэнзіі. Калі ласка, звяжыцеся з сістэмным адміністратарам наконт паляпшэння вашай карпаратыўнай ліцэнзіі." + "translation": "Функцыянальнасць адпаведнасці адключана бягучай ліцэнзіяй. Калі ласка, звярніцеся да вашага сістэмнага адміністратара адносна абнаўлення вашай прадпрыемніцкай ліцэнзіі." }, { "id": "ent.cluster.timeout.error", - "translation": "Тайм-аўт чакання адказу кластара" + "translation": "Час чакання адказу кластара скончыўся." }, { "id": "ent.cluster.save_config.error", - "translation": "Сістэмная кансоль усталёўваецца толькі для чытання, калі ўключаная Высокая даступнасць, калі толькі ReadOnlyConfig не адключаны ў файле канфігурацыі." + "translation": "Сістэмная кансоль устаноўлена толькі для чытання, калі ўключана высокая даступнасць, калі толькі ReadOnlyConfig не адключаны ў канфігурацыйным файле." }, { "id": "ent.cluster.json_encode.error", - "translation": "Адбылася памылка падчас маршалявання запыту JSON" + "translation": "Памылка падчас серыялізацыі JSON-запыту." }, { "id": "ent.cluster.config_changed.info", - "translation": "Канфігурацыя кластара змянілася для id={{ .id }}. Кластар можа стаць нестабільным, і патрабуецца перазагрузка. Каб пераканацца, што кластар настроены правільна, вы павінны неадкладна выканаць паўторны перазапуск." + "translation": "Канфігурацыя кластара змянілася для id={{ .id }}. Кластар можа стаць нестабільным і патрабуе перазапуску. Каб пераканацца, што кластар наладжаны правільна, вы павінны неадкладна выканаць паслядоўны перазапуск." }, { "id": "ent.account_migration.get_saml_users_failed", - "translation": "Не ўдалося атрымаць SAML карыстальнікаў." + "translation": "Немагчыма атрымаць SAML-карыстальнікаў." }, { "id": "ent.account_migration.get_all_failed", - "translation": "Не ўдалося атрымаць карыстальнікаў." + "translation": "Немагчыма атрымаць карыстальнікаў." }, { "id": "brand.save_brand_image.save_image.app_error", - "translation": "Немагчыма запісаць файл выявы ў сховішча файлаў. Калі ласка, праверце злучэнне і паспрабуйце зноў." + "translation": "Немагчыма запісаць файл выявы ў ваша сховішча файлаў. Калі ласка, праверце ваша злучэнне і паспрабуйце яшчэ раз." }, { "id": "brand.save_brand_image.open.app_error", - "translation": "Немагчыма загрузіць уласную выяву брэнда. Пераканайцеся, што памер выявы не перавышае 2 МБ, і паспрабуйце яшчэ раз." + "translation": "Немагчыма загрузіць карыстальніцкую выяву брэнда. Пераканайцеся, што памер выявы меншы за 2 МБ, і паспрабуйце яшчэ раз." }, { "id": "brand.save_brand_image.encode.app_error", - "translation": "Немагчыма пераўтварыць дадзеныя выявы ў фармат PNG. Калі ласка, паспрабуйце яшчэ раз." + "translation": "Немагчыма пераўтварыць даныя выявы ў фармат PNG. Калі ласка, паспрабуйце яшчэ раз." }, { "id": "brand.save_brand_image.decode.app_error", - "translation": "Немагчыма дэкадаваць дадзеныя выявы." + "translation": "Немагчыма дэкадаваць даныя выявы." }, { "id": "brand.save_brand_image.check_image_limits.app_error", - "translation": "Памылка праверкі абмежаванняў выявы. Дазвол занадта высокае." + "translation": "Праверка абмежаванняў выявы не ўдалася. Дазвол занадта высокі." }, { "id": "app.webhooks.update_outgoing.app_error", - "translation": "Не ўдалося абнавіць вэбхук." + "translation": "Немагчыма абнавіць вэб-хук." }, { "id": "app.file_info.get.app_error", - "translation": "Не атрымалася атрымаць інфармацыю аб файле." + "translation": "Немагчыма атрымаць інфармацыю пра файл." }, { "id": "app.export.zip_create.error", - "translation": "Не ўдалося дадаць файл у zip-архіў пры экспарце." + "translation": "Не атрымалася дадаць файл у ZIP-архіў падчас экспарту." }, { "id": "app.export.marshal.app_error", - "translation": "Немагчыма арганізаваць адказ." + "translation": "Немагчыма апрацаваць адказ." }, { "id": "app.export.export_write_line.json_marshall.error", - "translation": "Адбылася памылка пры сартаванні JSON-дадзеных для экспарту." + "translation": "Адбылася памылка пры фарміраванні даных JSON для экспарту." }, { "id": "app.export.export_write_line.io_writer.error", - "translation": "Адбылася памылка пры запісе дадзеных экспарту." + "translation": "Адбылася памылка пры запісе даных экспарту." }, { "id": "app.export.export_custom_emoji.copy_emoji_images.error", - "translation": "Немагчыма скапіяваць прыстасаваныя выявы смайлікаў" + "translation": "Немагчыма скапіяваць выявы карыстальніцкіх эмодзі" }, { "id": "app.export.export_attachment.zip_create_header.error", - "translation": "Не ўдалося стварыць загаловак ZIP-архіва пры экспарце." + "translation": "Не атрымалася стварыць загаловак ZIP-архіва падчас экспарту." }, { "id": "app.export.export_attachment.mkdirall.error", - "translation": "Не ўдалося стварыць каталог пры экспарце." + "translation": "Не атрымалася стварыць каталог падчас экспарту." }, { "id": "app.export.export_attachment.create_file.error", - "translation": "Не ўдалося стварыць файл пры экспарце." + "translation": "Не атрымалася стварыць файл падчас экспарту." }, { "id": "app.export.export_attachment.copy_file.error", - "translation": "Не ўдалося скапіяваць файл пры экспарце." + "translation": "Не атрымалася скапіяваць файл падчас экспарту." }, { "id": "app.emoji.get_list.internal_error", - "translation": "Не ўдалося атрымаць смайлік." + "translation": "Немагчыма атрымаць эмодзі." }, { "id": "app.emoji.get_by_name.no_result", - "translation": "Мы не змаглі знайсці смайлік." + "translation": "Мы не змаглі знайсці эмодзі." }, { "id": "app.emoji.get_by_name.app_error", - "translation": "Не ўдалося атрымаць смайлік." + "translation": "Немагчыма атрымаць эмодзі." }, { "id": "app.emoji.get.no_result", - "translation": "Мы не змаглі знайсці смайлік." + "translation": "Мы не змаглі знайсці эмодзі." }, { "id": "app.emoji.get.app_error", - "translation": "Не ўдалося атрымаць смайлік." + "translation": "Немагчыма атрымаць эмодзі." }, { "id": "app.emoji.delete.no_results", - "translation": "Не ўдалося знайсці смайлік для выдалення." + "translation": "Мы не змаглі знайсці эмодзі для выдалення." }, { "id": "app.emoji.delete.app_error", - "translation": "Не атрымалася выдаліць смайлік." + "translation": "Нельга выдаліць эмодзі." }, { "id": "app.emoji.create.internal_error", - "translation": "Не ўдалося захаваць эмоджы." + "translation": "Нельга захаваць эмодзі." }, { "id": "app.email.setup_rate_limiter.app_error", - "translation": "Адбылася памылка ў абмежавальніку хуткасці." + "translation": "Адбылася памылка ў абмежавальні хуткасці." }, { "id": "app.email.rate_limit_exceeded.app_error", - "translation": "Перавышаны мяжа колькасці рассылак па запрашэннях. Таймер будзе скінуты праз {{.ResetAfter}} секунд. Паўтарыце спробу праз {{.RetryAfter}} секунд." + "translation": "Перавышаны ліміт запытаў на адпраўку запрашэнняў па электроннай пошце. Таймер будзе скіраваны праз {{.ResetAfter}} секунд. Калі ласка, паспрабуйце зноў праз {{.RetryAfter}} секунд." }, { "id": "app.email.no_rate_limiter.app_error", - "translation": "Абмежавальнік хуткасці не настроены." + "translation": "Абмежавальнік хуткасці не наладжаны." }, { "id": "app.create_basic_user.save_member.max_accounts.app_error", - "translation": "Немагчыма стварыць удзел у камандзе па змаўчанні, таму што ў гэтай камандзе не дазволены дадатковыя ўдзельнікі" + "translation": "Нельга стварыць стандартнае членства ў камандзе, бо ў гэтай камандзе не дазволены дадатковыя ўдзельнікі" }, { "id": "app.create_basic_user.save_member.conflict.app_error", - "translation": "Немагчыма стварыць удзел у камандзе па змаўчанні, таму што ён ужо існуе" + "translation": "Нельга стварыць стандартнае членства ў камандзе, бо яно ўжо існуе" }, { "id": "app.create_basic_user.save_member.app_error", - "translation": "Немагчыма стварыць удзел у камандзе па змаўчанні" + "translation": "Нельга стварыць стандартныя членствы ў камандзе" }, { "id": "app.compliance.save.saving.app_error", - "translation": "Узнікла памылка пры захаванні комплаенс-справаздачы." + "translation": "Мы сутыкнуліся з памылкай пры захаванні справаздачы аб адпаведнасці." }, { "id": "app.compliance.get.finding.app_error", - "translation": "Узнікла памылка з атрыманнем комплаенс-справаздач." + "translation": "Мы сутыкнуліся з памылкай пры атрыманні справаздач аб адпаведнасці." }, { "id": "app.command_webhook.try_use.internal_error", - "translation": "Немагчыма выкарыстоўваць вебхук." + "translation": "Нельга выкарыстоўваць вэбхук." }, { "id": "app.command_webhook.handle_command_webhook.parse", - "translation": "Немагчыма разабраць уваходныя дадзеныя." + "translation": "Нельга разабраць уваходныя даныя." }, { "id": "app.command_webhook.get.missing", - "translation": "Не ўдалося знайсці вэбхук." + "translation": "Не атрымалася знайсці вэбхук." }, { "id": "app.command_webhook.get.internal_error", - "translation": "Не ўдалося атрымаць вэбхук." + "translation": "Не атрымалася атрымаць вэбхук." }, { "id": "app.command_webhook.create_command_webhook.internal_error", - "translation": "Немагчыма захаваць CommandWebhook." + "translation": "Нельга захаваць CommandWebhook." }, { "id": "app.command_webhook.create_command_webhook.existing", - "translation": "Вы не можаце абнавіць існуючы CommandWebhook." + "translation": "Нельга абнавіць існуючы CommandWebhook." }, { "id": "app.command.updatecommand.internal_error", - "translation": "Не ўдалося абнавіць каманду." + "translation": "Не атрымалася абнавіць каманду." }, { "id": "app.command.tryexecutecustomcommand.internal_error", - "translation": "Не атрымалася выканаць карыстацкую каманду." + "translation": "Не атрымалася выканаць карыстальніцкую каманду." }, { "id": "app.command.regencommandtoken.internal_error", - "translation": "Не ўдалося перастварыць токен каманды." + "translation": "Не атрымалася перагенераваць токен каманды." }, { "id": "app.command.movecommand.internal_error", - "translation": "Немагчыма перамясціць каманду." + "translation": "Нельга перамясціць каманду." }, { "id": "app.command.listteamcommands.internal_error", - "translation": "Немагчыма атрымаць спіс каманд Каманды." + "translation": "Нельга атрымаць спіс каманд каманды." }, { "id": "app.command.listautocompletecommands.internal_error", - "translation": "Немагчыма атрымаць спіс каманд аўтазапаўнення." + "translation": "Нельга атрымаць спіс каманд аўтадапаўнення." }, { "id": "app.command.listallcommands.internal_error", - "translation": "Немагчыма атрымаць спіс каманд." + "translation": "Нельга атрымаць спіс каманд." }, { "id": "app.command.getcommand.internal_error", - "translation": "Немагчыма атрымаць каманду." + "translation": "Не атрымалася атрымаць каманду." }, { "id": "app.command.deletecommand.internal_error", - "translation": "Немагчыма выдаліць каманду." + "translation": "Нельга выдаліць каманду." }, { "id": "app.command.createcommand.internal_error", - "translation": "Немагчыма захаваць каманду." + "translation": "Нельга захаваць каманду." }, { "id": "app.channel_member_history.log_leave_event.internal_error", - "translation": "Не ўдалося запісаць гісторыю ўдзельнікаў канала. Не ўдалося абнавіць існуючы запіс аб далучэнні" + "translation": "Немагчыма запісаць гісторыю ўдзельнікаў канала. Немагчыма абнавіць існуючы запіс аб далучэнні" }, { "id": "app.channel_member_history.log_join_event.internal_error", - "translation": "Не ўдалося запісаць гісторыю ўдзельнікаў канала." + "translation": "Немагчыма запісаць гісторыю ўдзельнікаў канала." }, { "id": "app.channel.user_belongs_to_channels.app_error", - "translation": "Немагчыма вызначыць, ці належыць карыстач да спісу каналаў." + "translation": "Немагчыма вызначыць, ці належыць карыстальнік да спісу каналаў." }, { "id": "app.channel.update_last_viewed_at_post.app_error", @@ -4677,39 +4677,39 @@ }, { "id": "app.channel.update_last_viewed_at.app_error", - "translation": "Не ўдалося ўстанавіць час апошняга прагляду." + "translation": "Немагчыма абнавіць час апошняга прагляду." }, { "id": "app.channel.update_channel.internal_error", - "translation": "Не ўдалося абнавіць канал." + "translation": "Немагчыма абнавіць канал." }, { "id": "app.channel.update.bad_id", - "translation": "Не ўдалося абнавіць канал." + "translation": "Немагчыма абнавіць канал." }, { "id": "app.channel.sidebar_categories.app_error", - "translation": "Не ўдалося ўставіць запіс у базу дадзеных." + "translation": "Немагчыма ўставіць запіс у базу дадзеных." }, { "id": "app.channel.search_group_channels.app_error", - "translation": "Немагчыма атрымаць групавыя каналы для дадзенага карыстальніка і зададзеных умоў." + "translation": "Немагчыма атрымаць групавыя каналы для дадзенага карыстальніка і тэрміна." }, { "id": "app.channel.search.app_error", - "translation": "Узнікла праблема пры пошуку канала." + "translation": "Мы сутыкнуліся з памылкай пры пошуку каналаў." }, { "id": "app.channel.save_member.exists.app_error", - "translation": "Удзельнік канала з такім ID ужо існуе." + "translation": "Удзельнік канала з такім ідэнтыфікатарам ужо існуе." }, { "id": "app.channel.restore.app_error", - "translation": "Не ўдалося аднавіць канал." + "translation": "Немагчыма аднавіць канал." }, { "id": "app.channel.reset_all_channel_schemes.app_error", - "translation": "Мы не змаглі скінуць схемы канала." + "translation": "Мы не змаглі скінуць схемы каналаў." }, { "id": "app.channel.remove_member.app_error", @@ -4717,11 +4717,11 @@ }, { "id": "app.bot.getbots.internal_error", - "translation": "Не ўдалося атрымаць ботаў." + "translation": "Немагчыма атрымаць ботаў." }, { "id": "app.bot.getbot.internal_error", - "translation": "Не ўдалося атрымаць бота." + "translation": "Немагчыма атрымаць бота." }, { "id": "app.bot.get_system_bot.empty_admin_list.app_error", @@ -4729,15 +4729,15 @@ }, { "id": "app.bot.get_disable_bot_sysadmin_message", - "translation": "{{if .disableBotsSetting}}{{if .printAllBots}}{{.UserName}} былі дэактываваны. Яны кіравалі наступнымі ўліковымі запісамі ботаў, якія зараз адключаныя.\n\n{{.BotNames}}{{else}}{{.UserName}} былі дэактываваны. Яны кіравалі {{.NumBots}} уліковымі запісамі ботаў, якія зараз адключаныя, уключаючы наступныя:\n\n{{.BotNames}}{{end}} Вы можаце стаць уладальнікам кожнага бота, улучыўшы яго ў раздзеле **Інтэграцыі > Уліковыя запісы ботаў** і стварыўшы новыя токены для бота.\n\nДля атрымання дадатковай інфармацыі гл. Нашу [дакументацыю](https://docs.mattermost.com/developer/bot-accounts.html#what-happens-when-a-user-who-owns-bot-accounts-is-disabled ).{{else}}{{if .printAllBots}}{{.UserName}} былі дэактываваны. Яны кіравалі наступнымі ўліковымі запісамі ботаў, якія ўсё яшчэ ўключаны.\n\n{{.BotNames}}\n{{else}}{{.UserName}} былі дэактываваны. Яны кіравалі {{.NumBots}} уліковымі запісамі ботаў, якія па-ранейшаму ўключаны, уключаючы наступныя:\n\n{{.BotNames}}{{end}} Мы настойліва раім вам стаць уладальнікам кожнага бота, паўторна ўключыўшы яго ў раздзеле **Інтэграцыя > Уліковыя запісы ботаў** і стварыўшы новыя токены для бота.\n\nДля атрымання дадатковай інфармацыі гл. Нашу [дакументацыю](https://docs.mattermost.com/developer/bot-accounts.html#what-happens-when-a-user-who-owns-bot-accounts-is-disabled ).\n\nКалі вы хочаце, каб уліковыя запісы ботаў аўтаматычна адключаліся пасля дэактывацыі карыстальніка, усталюйце для параметра «Адключыць уліковыя запісы ботаў пасля дэактывацыі карыстальніка» у **Сістэмная кансоль > Інтэграцыя > Уліковыя запісы ботаў** значэнне \"так\".{{end}}" + "translation": "{{if .disableBotsSetting}}{{if .printAllBots}}{{.UserName}} быў дэактываваны. Яны кіравалі наступнымі ўліковымі запісамі ботаў, якія цяпер былі адключаны.\n\n{{.BotNames}}{{else}}{{.UserName}} быў дэактываваны. Яны кіравалі {{.NumBots}} уліковымі запісамі ботаў, якія цяпер былі адключаны, у тым ліку наступнымі:\n\n{{.BotNames}}{{end}}Вы можаце ўзяць на сябе кіраванне кожным ботам, уключыўшы яго ў раздзеле **Інтэграцыі > Уліковыя запісы ботаў** і стварыўшы новыя токены для бота.\n\nДля атрымання дадатковай інфармацыі, звярніцеся да нашай [дакументацыі](https://docs.mattermost.com/developer/bot-accounts.html#what-happens-when-a-user-who-owns-bot-accounts-is-disabled).{{else}}{{if .printAllBots}}{{.UserName}} быў дэактываваны. Яны кіравалі наступнымі ўліковымі запісамі ботаў, якія ўсё яшчэ ўключаны.\n\n{{.BotNames}}\n{{else}}{{.UserName}} быў дэактываваны. Яны кіравалі {{.NumBots}} уліковымі запісамі ботаў, якія ўсё яшчэ ўключаны, у тым ліку наступнымі:\n\n{{.BotNames}}{{end}}Мы настойліва рэкамендуем вам узяць на сябе кіраванне кожным ботам, уключыўшы яго зноў у раздзеле **Інтэграцыі > Уліковыя запісы ботаў** і стварыўшы новыя токены для бота.\n\nДля атрымання дадатковай інфармацыі, звярніцеся да нашай [дакументацыі](https://docs.mattermost.com/developer/bot-accounts.html#what-happens-when-a-user-who-owns-bot-accounts-is-disabled).\n\nКалі вы хочаце, каб уліковыя запісы ботаў адключаліся аўтаматычна пасля дэактывацыі ўладальніка, усталюйце \"Адключыць уліковыя запісы ботаў, калі ўладальнік дэактываваны\" у **Сістэмная кансоль > Інтэграцыі > Уліковыя запісы ботаў** на \"true\".{{end}}" }, { "id": "app.bot.createbot.internal_error", - "translation": "Не ўдалося захаваць робата." + "translation": "Немагчыма захаваць бота." }, { "id": "app.audit.save.saving.app_error", - "translation": "Мы сутыкнуліся з памылкай пры захаванні аўдытаў." + "translation": "Мы сутыкнуліся з памылкай пры захаванні аўдыта." }, { "id": "app.audit.permanent_delete_by_user.app_error", @@ -4745,11 +4745,11 @@ }, { "id": "app.audit.get.limit.app_error", - "translation": "Перавышаны ліміт для падзелу па старонках." + "translation": "Перавышаны ліміт для пагінацыі." }, { "id": "app.audit.get.finding.app_error", - "translation": "Мы знайшлі памылку пры пошуку аўдытаў." + "translation": "Мы сутыкнуліся з памылкай пры пошуку аўдытаў." }, { "id": "app.analytics.getanalytics.internal_error", @@ -4757,19 +4757,19 @@ }, { "id": "app.admin.test_site_url.failure", - "translation": "Гэта URL спасылка не працуе" + "translation": "Гэта несапраўдны актыўны URL" }, { "id": "app.admin.saml.invalid_response_from_idp.app_error", - "translation": "Не ўдалося прачытаць адказ, атрыманы ад пастаўшчыка пасведчанняў." + "translation": "Не атрымалася прачытаць адказ, атрыманы ад пастаўшчыка ідэнтыфікацыі." }, { "id": "app.admin.saml.failure_read_response_body_from_idp.app_error", - "translation": "Выяўлена памылка пры чытанні карыснай нагрузкі адказу, атрыманай ад пастаўшчыка пасведчанняў." + "translation": "Памылка пры чытанні payload адказу, атрыманага ад пастаўшчыка ідэнтыфікацыі." }, { "id": "app.admin.saml.failure_decode_metadata_xml_from_idp.app_error", - "translation": "Не атрымалася дэкадаваць інфармацыю метададзеных XML, атрыманую ад пастаўшчыка пасведчанняў." + "translation": "Не атрымалася дэкадаваць XML-метаданыя, атрыманыя ад пастаўшчыка ідэнтыфікацыі." }, { "id": "api.websocket_handler.server_busy.app_error", @@ -4777,71 +4777,71 @@ }, { "id": "api.webhook.update_outgoing.intersect.app_error", - "translation": "Выходныя вэбхукі аднаго канала не могуць мець аднолькавыя словы-трыгеры / выкліканыя URL." + "translation": "Выходныя вэбхукі з аднаго канала не могуць мець аднолькавыя словы-трыгеры/URL зваротнага выкліку." }, { "id": "api.webhook.team_mismatch.app_error", - "translation": "Не ўдалося абнавіць вэбхук у розных камандах." + "translation": "Немагчыма абнавіць вэбхук паміж камандамі." }, { "id": "api.webhook.create_outgoing.triggers.app_error", - "translation": "Павінны быць зададзены trigger_words ці channel_id." + "translation": "Павінна быць устаноўлена trigger_words або channel_id." }, { "id": "api.webhook.create_outgoing.permissions.app_error", - "translation": "Неадпаведныя правы для стварэння выходнага вэбхука." + "translation": "Няправільныя дазволы на стварэнне выходнага вэбхука." }, { "id": "api.webhook.create_outgoing.not_open.app_error", - "translation": "Выходныя вэбхукі могуць быць створаны толькі для грамадскіх каналаў." + "translation": "Выходныя вэбхукі могуць быць створаны толькі для публічных каналаў." }, { "id": "api.webhook.create_outgoing.intersect.app_error", - "translation": "Выходныя вэбхукі аднаго канала не могуць мець аднолькавыя словы-трыгеры / выкліканыя URL." + "translation": "Выходныя вэбхукі з аднаго канала не могуць мець аднолькавыя словы-трыгеры/URL зваротнага выкліку." }, { "id": "api.web_socket_router.not_authenticated.app_error", - "translation": "Падлучэнне WebSocket не мінула праверку сапраўднасці. Калі ласка, увайдзіце ў сістэму і паспрабуйце яшчэ раз." + "translation": "Злучэнне WebSocket не аўтэнтыфікавана. Калі ласка, увайдзіце і паспрабуйце зноў." }, { "id": "api.web_socket_router.bad_seq.app_error", - "translation": "Недапушчальная паслядоўнасць паведамленняў праз websocket." + "translation": "Няправільная паслядоўнасць для паведамлення WebSocket." }, { "id": "api.web_socket.connect.upgrade.app_error", - "translation": "Не ўдалося абнавіць злучэнне з web-сокетам." + "translation": "URL заблакаваны з-за CORS. URL: {{.BlockedOrigin}}" }, { "id": "api.user.verify_email.token_parse.error", - "translation": "Не ўдалося прааналізаваць дадзеныя токена з пацверджання па электроннай пошце" + "translation": "Не атрымалася разабраць даныя токена з пацвярджэння электроннай пошты" }, { "id": "api.user.verify_email.link_expired.app_error", - "translation": "Тэрмін дзеяння спасылкі для пацверджання адраса электроннай пошты скончыўся." + "translation": "Тэрмін дзеяння спасылкі для пацвярджэння электроннай пошты скончыўся." }, { "id": "api.user.verify_email.broken_token.app_error", - "translation": "Дрэнны тып токена праверкі email." + "translation": "Няправільны тып токена для пацвярджэння электроннай пошты." }, { "id": "api.user.verify_email.bad_link.app_error", - "translation": "Дрэнная спасылка праверкі email." + "translation": "Няправільная спасылка для пацвярджэння электроннай пошты." }, { "id": "api.user.upload_profile_user.upload_profile.app_error", - "translation": "Немагчыма загрузіць выяву профіля." + "translation": "Не атрымалася загрузіць выяву профілю." }, { "id": "api.user.upload_profile_user.too_large.app_error", - "translation": "Немагчыма загрузіць выяву. Файл занадта вялікі." + "translation": "Немагчыма загрузіць выяву профілю. Файл занадта вялікі." }, { "id": "api.user.upload_profile_user.storage.app_error", - "translation": "Немагчыма загрузіць файл. Сховішча малюнкаў не наладжана." + "translation": "Немагчыма загрузіць файл. Захоўванне выяваў не наладжана." }, { "id": "api.user.upload_profile_user.parse.app_error", - "translation": "Не ўдалося разабраць складовую форму." + "translation": "Не атрымалася разабраць шматчастковы фармуляр." }, { "id": "api.user.upload_profile_user.open.app_error", @@ -4849,95 +4849,95 @@ }, { "id": "api.user.upload_profile_user.no_file.app_error", - "translation": "Няма файла 'image' у запыце." + "translation": "Няма файла ў раздзеле 'image' у запыце." }, { "id": "api.user.upload_profile_user.login_provider_attribute_set.app_error", - "translation": "Малюнак профілю павінна быць усталявана праз правайдэра аўтэнтыфікацыі карыстальніка." + "translation": "Выява профілю павінна быць устаноўлена праз пастаўшчыка ўваходу карыстальніка." }, { "id": "api.user.upload_profile_user.encode.app_error", - "translation": "Не атрымалася закадаваць выяву профіля." + "translation": "Не атрымалася закадаваць выяву профілю." }, { "id": "api.user.upload_profile_user.decode.app_error", - "translation": "Не атрымалася дэкадаваць выяву профіля." + "translation": "Не атрымалася дэкадаваць выяву профілю." }, { "id": "api.user.upload_profile_user.check_image_limits.app_error", - "translation": "Памылка праверкі абмежаванняў выявы. Дазвол занадта высокае." + "translation": "Праверка абмежаванняў выявы не прайшла. Дазвол занадта высокі." }, { "id": "api.user.upload_profile_user.array.app_error", - "translation": "Пусты масіў 'image' у запыце." + "translation": "Пусты масіў у раздзеле 'image' у запыце." }, { "id": "api.user.update_user_roles.license.app_error", - "translation": "Дадзеныя схемы карыстацкіх правоў не падтрымліваюцца бягучай ліцэнзіяй" + "translation": "Карыстальніцкія схемы дазволаў не падтрымліваюцца бягучай ліцэнзіяй" }, { "id": "api.user.update_user_auth.invalid_request", - "translation": "У запыце адсутнічае альбо параметр AuthData, альбо параметр AuthService." + "translation": "У запыце адсутнічае параметр AuthData або AuthService." }, { "id": "api.user.update_user.login_provider_attribute_set.app_error", - "translation": "Поле '{{.Field}}' павінна быць усталявана праз правайдэра аўтэнтыфікацыі карыстальніка." + "translation": "Поле '{{.Field}}' павінна быць устаноўлена праз пастаўшчыка ўваходу карыстальніка." }, { "id": "api.user.update_user.accepted_guest_domain.app_error", - "translation": "Указаны вамі адрас электроннай пошты не належыць прызнанаму дамену для гасцявых уліковых запісаў. Калі ласка, звяжыцеся з вашым адміністратарам ці зарэгіструйцеся з іншым адрасам электроннай пошты." + "translation": "Указаны вамі адрас электроннай пошты не належыць да прызнанай дамена для гасцявых уліковых запісаў. Калі ласка, звярніцеся да адміністратара або зарэгіструйцеся з іншым адрасам электроннай пошты." }, { "id": "api.user.update_user.accepted_domain.app_error", - "translation": "Указаны вамі адрас электроннай пошты не належыць прызнанаму дамену. Калі ласка, звяжыцеся з вашым адміністратарам ці зарэгіструйцеся з іншым адрасам электроннай пошты." + "translation": "Указаны вамі адрас электроннай пошты не належыць да прызнанай дамена. Калі ласка, звярніцеся да адміністратара або зарэгіструйцеся з іншым адрасам электроннай пошты." }, { "id": "api.user.update_password.valid_account.app_error", - "translation": "Не атрымалася абнавіць пароль, паколькі мы не змаглі знайсці сапраўдны ўліковы запіс." + "translation": "Абнаўленне пароля не атрымалася, бо мы не змаглі знайсці сапраўдны ўліковы запіс." }, { "id": "api.user.update_password.user_and_hashed.app_error", - "translation": "Толькі сістэмныя адміністратары могуць усталёўваць ужо хэшаваныя паролі." + "translation": "Толькі сістэмныя адміністратары могуць усталёўваць ужо хешаваныя паролі." }, { "id": "api.user.update_password.oauth.app_error", - "translation": "Не атрымалася абнавіць пароль, паколькі карыстач увайшоў праз службу OAuth." + "translation": "Абнаўленне пароля не атрымалася, бо карыстальнік увайшоў праз службу OAuth." }, { "id": "api.user.update_password.menu", - "translation": "выкарыстоўваючы меню настроек" + "translation": "праз меню налад" }, { "id": "api.user.update_password.incorrect.app_error", - "translation": "Уведзены вамі \"Бягучы пароль\" няверны. Калі ласка, праверце выключаны Caps Lock і паспрабуйце зноў." + "translation": "Уведзены вамі \"Бягучы пароль\" няправільны. Калі ласка, праверце, ці выключаны Caps Lock, і паспрабуйце зноў." }, { "id": "api.user.update_password.context.app_error", - "translation": "Не атрымалася абнавіць пароль, паколькі кантэкст user_id не адпавядаў рэквізітам user_id." + "translation": "Абнаўленне пароля не атрымалася, бо user_id у кантэксце не супадаў з user_id у props." }, { "id": "api.user.update_oauth_user_attrs.get_user.app_error", - "translation": "Немагчыма атрымаць карыстальніка з {{.Service}} аб'екта карыстальніка." + "translation": "Не атрымалася атрымаць карыстальніка з аб'екта карыстальніка {{.Service}}." }, { "id": "api.user.update_active.permissions.app_error", - "translation": "У вас няма адпаведных правоў." + "translation": "Вы не маеце адпаведных дазволаў." }, { "id": "api.user.update_active.not_enable.app_error", - "translation": "Вы не можаце дэактываваць сябе самастойна, таму што гэтая функцыя не ўключана. Калі ласка, звяжыцеся са сваім сістэмным адміністратарам." + "translation": "Вы не можаце дэактываваць сябе самастойна, бо гэтая функцыя не ўключана. Калі ласка, звярніцеся да вашага сістэмнага адміністратара." }, { "id": "api.user.update_active.cannot_enable_guest_when_guest_feature_is_disabled.app_error", - "translation": "Вы не можаце актываваць гасцявы ўліковы запіс, таму што функцыя гасцявога доступу не ўключана." + "translation": "Вы не можаце актываваць гасцявы ўліковы запіс, бо функцыя \"Доступ для гасцей\" не ўключана." }, { "id": "api.user.send_verify_email_and_forget.failed.error", - "translation": "Не ўдалося адправіць ліст з пацверджаннем" + "translation": "Не атрымалася паспяхова адправіць электроннае пісьмо з пацвярджэннем" }, { "id": "api.user.send_sign_in_change_email_and_forget.error", - "translation": "Не атрымалася адправіць ліст аб абнаўленні пароля" + "translation": "Не атрымалася паспяхова адправіць электроннае пісьмо аб абнаўленні пароля" }, { "id": "api.user.send_password_reset.sso.app_error", @@ -4945,27 +4945,27 @@ }, { "id": "api.user.send_password_reset.send.app_error", - "translation": "Не ўдалося адправіць ліст аб паспяховым скідзе пароля." + "translation": "Не атрымалася паспяхова адправіць электроннае пісьмо для скідання пароля." }, { "id": "api.user.send_email_change_verify_email_and_forget.error", - "translation": "Не ўдалося адправіць ліст з пацвярджэннем змены адраса электроннай пошты" + "translation": "Не атрымалася паспяхова адправіць электроннае пісьмо з пацвярджэннем змены адрасу электроннай пошты" }, { "id": "api.user.send_deactivate_email_and_forget.failed.error", - "translation": "Не атрымалася адправіць паведамленне на электронную пошту аб паспяховай дэактывацыі акаўнта" + "translation": "Не атрымалася паспяхова адправіць электроннае пісьмо аб дэактывацыі ўліковага запісу" }, { "id": "api.user.send_cloud_welcome_email.error", - "translation": "Не ўдалося адправіць прывітальны ліст па электроннай пошце" + "translation": "Не атрымалася адправіць прывітальнае электроннае пісьмо для воблака" }, { "id": "api.user.saml.not_available.app_error", - "translation": "SAML 2.0 не наладжаны ці не падтрымліваецца на гэтым серверы." + "translation": "SAML 2.0 не наладжаны або не падтрымліваецца на гэтым серверы." }, { "id": "api.user.reset_password.token_parse.error", - "translation": "Немагчыма прааналізаваць токен скіду пароля" + "translation": "Немагчыма разабраць токен скідання пароля" }, { "id": "api.user.reset_password.sso.app_error", @@ -4973,39 +4973,39 @@ }, { "id": "api.user.reset_password.method", - "translation": "выкарыстоўваючы спасылку для скіду пароля" + "translation": "выкарыстоўваючы спасылку скідання пароля" }, { "id": "api.user.reset_password.link_expired.app_error", - "translation": "Спасылка для скіду пароля пратэрмінаваная." + "translation": "Спасылка скідання пароля састарэла." }, { "id": "api.user.reset_password.invalid_link.app_error", - "translation": "Спасылка скіду пароля не ўяўляецца сапраўднай." + "translation": "Спасылка скідання пароля выглядае несапраўднай." }, { "id": "api.user.reset_password.broken_token.app_error", - "translation": "Токен скіду пароля не ўяўляецца сапраўдным." + "translation": "Токен скідання пароля выглядае несапраўдным." }, { "id": "api.user.promote_guest_to_user.no_guest.app_error", - "translation": "Немагчыма пераўтварыць госця ў звычайнага карыстальніка, таму што ён не госць." + "translation": "Немагчыма пераўтварыць госця ў звычайнага карыстальніка, бо ён не з'яўляецца госцем." }, { "id": "api.user.patch_user.login_provider_attribute_set.app_error", - "translation": "Поле '{{.Field}}' павінна быць усталявана праз правайдэра аўтэнтыфікацыі карыстальніка." + "translation": "Поле '{{.Field}}' павінна быць устаноўлена праз пастаўшчыка ўваходу карыстальніка." }, { "id": "api.user.oauth_to_email.not_available.app_error", - "translation": "Перадача аўтэнтыфікацыі не наладжана ці недаступная на гэтым серверы." + "translation": "Перадача аўтэнтыфікацыі не наладжана або недаступная на гэтым серверы." }, { "id": "api.user.oauth_to_email.context.app_error", - "translation": "Не ўдалося абнавіць пароль, паколькі кантэкст user_id не адпавядае дадзенаму ідэнтыфікатару карыстальніка." + "translation": "Абнаўленне пароля не атрымалася, бо user_id у кантэксце не супадаў з user_id прадастаўленага карыстальніка." }, { "id": "api.user.login_ldap.not_available.app_error", - "translation": "AD/LDAP недаступны на гэтым сэрвэры." + "translation": "AD/LDAP недаступны на гэтым серверы." }, { "id": "api.user.login_cws.license.error", @@ -5013,15 +5013,15 @@ }, { "id": "api.user.login_by_oauth.parse.app_error", - "translation": "Немагчыма распарсіць дадзеныя аўтарызацыі з {{.Service}} аб'екта карыстальніка." + "translation": "Не атрымалася разабраць аўтэнтыфікацыйныя даныя з аб'екта карыстальніка {{.Service}}." }, { "id": "api.user.login_by_oauth.not_available.app_error", - "translation": "{{.Service}} SSO праз OAuth 2.0 не падтрымліваецца на гэтым серверы." + "translation": "{{.Service}} SSO праз OAuth 2.0 недаступны на гэтым серверы." }, { "id": "api.user.login_by_oauth.bot_login_forbidden.app_error", - "translation": "Лагін бота забаронены." + "translation": "Уваход бота забаронены." }, { "id": "api.user.login_by_cws.invalid_token.app_error", @@ -5033,7 +5033,7 @@ }, { "id": "api.user.login.not_verified.app_error", - "translation": "Уваход не атрымаўся, паколькі адрас электроннай пошты не быў пацверджаны." + "translation": "Уваход не атрымаўся, бо адрас электроннай пошты не быў пацверджаны." }, { "id": "api.user.login.invalid_credentials_username", @@ -5041,11 +5041,11 @@ }, { "id": "api.user.login.invalid_credentials_sso", - "translation": "Увядзіце сапраўдны адрас электроннай пошты ці імя карыстальніка і/або пароль, або ўвайдзіце ў сістэму, выкарыстоўваючы іншы метад." + "translation": "Увядзіце сапраўдны адрас электроннай пошты або імя карыстальніка і/або пароль, або ўвайдзіце, выкарыстоўваючы іншы метад." }, { "id": "api.user.login.invalid_credentials_email_username", - "translation": "Увядзіце сапраўдны адрас электроннай пошты ці імя карыстальніка і/або пароль." + "translation": "Увядзіце сапраўдны адрас электроннай пошты або імя карыстальніка і/або пароль." }, { "id": "api.user.login.invalid_credentials_email", @@ -5053,7 +5053,7 @@ }, { "id": "api.user.login.inactive.app_error", - "translation": "Уваход не атрымаўся, паколькі ваш уліковы запіс адключаны. Калі ласка, звяжыцеся з адміністратарам." + "translation": "Уваход не атрымаўся, бо ваш уліковы запіс быў дэактываваны. Калі ласка, звярніцеся да адміністратара." }, { "id": "api.user.login.guest_accounts.license.error", @@ -5061,87 +5061,87 @@ }, { "id": "api.user.login.guest_accounts.disabled.error", - "translation": "Гасцявыя ўліковыя запісы адключаныя" + "translation": "Гасцявыя ўліковыя запісы адключаны" }, { "id": "api.user.login.bot_login_forbidden.app_error", - "translation": "Лагін бота забаронены." + "translation": "Уваход бота забаронены." }, { "id": "api.user.login.blank_pwd.app_error", - "translation": "Поле з паролем не павінна быць пустым" + "translation": "Поле пароля не павінна быць пустым" }, { "id": "api.user.ldap_to_email.not_ldap_account.app_error", - "translation": "Уліковы запіс карыстальніка не выкарыстоўвае AD/LDAP." + "translation": "Гэты ўліковы запіс карыстальніка не выкарыстоўвае AD/LDAP." }, { "id": "api.user.ldap_to_email.not_available.app_error", - "translation": "AD/LDAP недаступны на гэтым сэрвэры." + "translation": "AD/LDAP недаступны на гэтым серверы." }, { "id": "api.user.invalidate_verify_email_tokens_parse.error", - "translation": "Немагчыма прааналізаваць токен пры ануляванні токенаў пацверджання электроннай пошты" + "translation": "Немагчыма разабраць токен пры ануляванні токенаў праверкі электроннай пошты" }, { "id": "api.user.invalidate_verify_email_tokens_delete.error", - "translation": "Немагчыма выдаліць токен пры ануляванні токенаў пацверджання электроннай пошты" + "translation": "Немагчыма выдаліць токен пры ануляванні токенаў праверкі электроннай пошты" }, { "id": "api.user.invalidate_verify_email_tokens.error", - "translation": "Немагчыма атрымаць токены па тыпе пры ануляванні токенаў пацверджання электроннай пошты" + "translation": "Немагчыма атрымаць токены па тыпе пры ануляванні токенаў праверкі электроннай пошты" }, { "id": "api.user.get_user_by_email.permissions.app_error", - "translation": "Немагчыма вызначыць карыстальніка па электроннай пошце." + "translation": "Немагчыма знайсці карыстальніка па электроннай пошце." }, { "id": "api.user.get_uploads_for_user.forbidden.app_error", - "translation": "Не ўдалося атрымаць загрузкі." + "translation": "Не атрымалася атрымаць загрузкі." }, { "id": "api.user.get_authorization_code.endpoint.app_error", - "translation": "Памылка атрымання endpoint з Discovery Document." + "translation": "Памылка атрымання канцавога пункта з Discovery Document." }, { "id": "api.user.email_to_oauth.not_available.app_error", - "translation": "Перадача аўтэнтыфікацыі не наладжана ці недаступная на гэтым серверы." + "translation": "Перадача аўтэнтыфікацыі не наладжана або недаступная на гэтым серверы." }, { "id": "api.user.email_to_ldap.not_available.app_error", - "translation": "AD/LDAP недаступны на гэтым сэрвэры." + "translation": "AD/LDAP недаступны на гэтым серверы." }, { "id": "api.user.demote_user_to_guest.already_guest.app_error", - "translation": "Немагчыма пераўтварыць карыстальніка ў госця, паколькі ён ужо з'яўляецца госцем." + "translation": "Немагчыма пераўтварыць карыстальніка ў госця, бо ён ужо з'яўляецца госцем." }, { "id": "api.user.delete_user.not_enabled.app_error", - "translation": "Функцыя беззваротнага выдалення карыстальніка не ўключана. Калі ласка, звярніцеся да сістэмнага адміністратара." + "translation": "Функцыя пастаяннага выдалення карыстальніка не ўключана. Калі ласка, звярніцеся да сістэмнага адміністратара." }, { "id": "api.user.delete_team.not_enabled.app_error", - "translation": "Функцыя выдалення каманды назаўжды не ўключана. Калі ласка, звярніцеся да сістэмнага адміністратара." + "translation": "Функцыя пастаяннага выдалення каманды не ўключана. Калі ласка, звярніцеся да сістэмнага адміністратара." }, { "id": "api.user.delete_channel.not_enabled.app_error", - "translation": "Функцыя выдалення каманды назаўжды недаступная. Калі ласка, звярніцеся да сістэмнага адміністратара." + "translation": "Функцыя пастаяннага выдалення канала не ўключана. Калі ласка, звярніцеся да сістэмнага адміністратара." }, { "id": "api.user.create_user.signup_link_invalid.app_error", - "translation": "Спасылка для рэгістрацыі, здаецца, няправільная." + "translation": "Спасылка для рэгістрацыі выглядае несапраўднай." }, { "id": "api.user.create_user.signup_link_expired.app_error", - "translation": "Спасылка для рэгістрацыі састарэлая." + "translation": "Спасылка для рэгістрацыі састарэла." }, { "id": "api.user.create_user.signup_email_disabled.app_error", - "translation": "Рэгістрацыя з электроннай поштай адключана." + "translation": "Рэгістрацыя карыстальнікаў па электроннай пошце адключана." }, { "id": "api.user.create_user.no_open_server", - "translation": "Гэты сервер не дазваляе адчыненую рэгістрацыю. Калі ласка, паразмаўляйце з Адміністратарам для атрымання запрашэння." + "translation": "Гэты сервер не дазваляе адкрытую рэгістрацыю. Калі ласка, звярніцеся да адміністратара, каб атрымаць запрашэнне." }, { "id": "api.user.create_user.invalid_invitation_type.app_error", @@ -5153,27 +5153,27 @@ }, { "id": "api.user.create_user.guest_accounts.disabled.app_error", - "translation": "Гасцявыя ўліковыя запісы адключаныя." + "translation": "Гасцявыя ўліковыя запісы адключаны." }, { "id": "api.user.create_user.disabled.app_error", - "translation": "Стварэнне акаўнтаў адключана." + "translation": "Стварэнне карыстальнікаў адключана." }, { "id": "api.user.create_user.accepted_domain.app_error", - "translation": "Указаны вамі адрас электроннай пошты не належыць прызнанаму дамену. Калі ласка, звяжыцеся з вашым адміністратарам ці зарэгіструйцеся з іншым адрасам электроннай пошты." + "translation": "Указаны вамі адрас электроннай пошты не належыць да прызнанай дамена. Калі ласка, звярніцеся да адміністратара або зарэгіструйцеся з іншым адрасам электроннай пошты." }, { "id": "api.user.create_profile_image.initial.app_error", - "translation": "Немагчыма дадаць ініцыялы карыстальніка да выявы профіля па змаўчанні." + "translation": "Не атрымалася дадаць ініцыялы карыстальніка да выявы профілю па змаўчанні." }, { "id": "api.user.create_profile_image.encode.app_error", - "translation": "Немагчыма закадаваць выяву профілю па змаўчанні." + "translation": "Не атрымалася закадаваць выяву профілю па змаўчанні." }, { "id": "api.user.create_profile_image.default_font.app_error", - "translation": "Немагчыма стварыць шрыфт выявы профіля па змаўчанні." + "translation": "Не атрымалася стварыць шрыфт для выявы профілю па змаўчанні." }, { "id": "api.user.create_password_token.error", @@ -5181,39 +5181,39 @@ }, { "id": "api.user.create_oauth_user.create.app_error", - "translation": "Немагчыма стварыць карыстальніка з {{.Service}} аб'екта карыстальніка." + "translation": "Не атрымалася стварыць карыстальніка з аб'екта карыстальніка {{.Service}}." }, { "id": "api.user.create_oauth_user.already_attached.app_error", - "translation": "Ужо існуе ўліковы запіс злучаны з гэтым адрасам электроннай пошты, якая выкарыстоўвае метад уваходу выдатны ад {{.Service}}. Калі ласка, увайдзіце, выкарыстоўваючы {{.Auth}}." + "translation": "Ужо існуе ўліковы запіс, звязаны з гэтым адрасам электроннай пошты, які выкарыстоўвае метад уваходу, адрозны ад {{.Service}}. Калі ласка, увайдзіце, выкарыстоўваючы {{.Auth}}." }, { "id": "api.user.create_email_token.error", - "translation": "Не ўдалося стварыць дадзеныя токена для праверкі электроннай пошты" + "translation": "Не атрымалася стварыць даныя токена для праверкі электроннай пошты" }, { "id": "api.user.complete_switch_with_oauth.parse.app_error", - "translation": "Немагчыма распарсіць дадзеныя аўтарызацыі з {{.Service}} аб'екта карыстальніка." + "translation": "Не атрымалася разабраць аўтэнтыфікацыйныя даныя з аб'екта карыстальніка {{.Service}}." }, { "id": "api.user.check_user_password.invalid.app_error", - "translation": "Памылка ўваходу, няправільна ўведзены пароль." + "translation": "Уваход не атрымаўся з-за няправільнага пароля." }, { "id": "api.user.check_user_login_attempts.too_many.app_error", - "translation": "Ваш уліковы запіс быў заблакаваны з-за занадта вялікай колькасці няўдалых спроб уводу пароля. Калі ласка, скіньце свой пароль." + "translation": "Ваш уліковы запіс заблакаваны з-за занадта вялікай колькасці няўдалых спроб уводу пароля. Калі ласка, скіньце свой пароль." }, { "id": "api.user.autocomplete_users.missing_team_id.app_error", - "translation": "Параметр ідэнтыфікатара каманды неабходны для аўтазапаўнення па канале." + "translation": "Параметр ідэнтыфікатара каманды неабходны для аўтаматычнага запаўнення па канале." }, { "id": "api.user.authorize_oauth_user.unsupported.app_error", - "translation": "{{.Service}} SSO праз OAuth 2.0 не падтрымліваецца на гэтым серверы." + "translation": "{{.Service}} SSO праз OAuth 2.0 недаступны на гэтым серверы." }, { "id": "api.user.authorize_oauth_user.service.app_error", - "translation": "Запыт токена для {{.Service}} не атрымаўся." + "translation": "Запыт токена да {{.Service}} не атрымаўся." }, { "id": "api.user.authorize_oauth_user.response.app_error", @@ -5221,67 +5221,67 @@ }, { "id": "api.user.authorize_oauth_user.bad_response.app_error", - "translation": "Дрэнны адказ ад запыту токена." + "translation": "Няправільны адказ на запыт токена." }, { "id": "api.user.add_direct_channels_and_forget.failed.error", - "translation": "Не атрымалася ўжыць наладкі прыватнага канала для карыстальніка: user_id={{.UserId}}, team_id={{.TeamId}}, err={{.Error}}" + "translation": "Не атрымалася дадаць налады прыватнага канала для карыстальніка user_id={{.UserId}}, team_id={{.TeamId}}, err={{.Error}}" }, { "id": "api.user.activate_mfa.email_and_ldap_only.app_error", - "translation": "МФА недаступная для дадзенага тыпу акаўнта." + "translation": "Шматфактарная аўтэнтыфікацыя (БФА) недаступная для гэтага тыпу ўліковага запісу." }, { "id": "api.upload.upload_data.multipart_error", - "translation": "Не ўдалося апрацаваць састаўныя даныя." + "translation": "Не атрымалася апрацаваць шматчастковыя даныя." }, { "id": "api.upload.upload_data.invalid_content_type", - "translation": "Недапушчальны Content-Type для складовай загрузкі." + "translation": "Няправільны тып зместу для шматчастковай загрузкі." }, { "id": "api.upload.get_upload.forbidden.app_error", - "translation": "Не ўдалося атрымаць загрузкі." + "translation": "Не атрымалася атрымаць загрузкі." }, { "id": "api.upgrade_to_enterprise_status.signature.app_error", - "translation": "Mattermost не ўдалося абнавіць да Карпаратыўнай рэдакцыі. Не атрымалася праверыць лічбавы подпіс загружанага двайковага файла." + "translation": "Mattermost не атрымалася абнавіць да Enterprise Edition. Лічбавы подпіс спампаванага бінарнага файла не атрымалася праверыць." }, { "id": "api.upgrade_to_enterprise_status.app_error", - "translation": "Mattermost не ўдалося абнавіць да Карпаратыўнай рэдакцыі." + "translation": "Mattermost не атрымалася абнавіць да Enterprise Edition." }, { "id": "api.upgrade_to_enterprise.system_not_supported.app_error", - "translation": "Mattermost не ўдалося абнавіць да Карпаратыўнай рэдакцыі. Гэта функцыя будзе працаваць толькі ў сістэмах Linux з архітэктурай x86-64." + "translation": "Mattermost не атрымалася абнавіць да Enterprise Edition. Гэтая функцыя будзе працаваць толькі на сістэмах Linux з архітэктурай x86-64." }, { "id": "api.upgrade_to_enterprise.invalid-user.app_error", - "translation": "Mattermost не ўдалося абнавіць да Карпаратыўнай рэдакцыі. Сістэмны карыстач Mattermost {{.MattermostUsername}} не мае правоў на запіс у неабходны двайковы файл. Сістэмны адміністратар можа абнавіць правы доступу да файлаў, выканаўшы наступную каманду на серверы, дзе ўсталяваны Mattermost:\n\n```\nchown {{.MattermostUsername}} \"{{.Path}}\"\n```\n\nПасля змены правоў доступу да файла паспрабуйце зноў абнавіць Mattermost. Пасля абнаўлення і перазапуску не забудзьцеся аднавіць зыходныя дазволы для двайковага файла:\n\n```\nchown {{.FileUsername}} \"{{.Path}}\"\n```" + "translation": "Mattermost не атрымалася абнавіць да Enterprise Edition. Сістэмны карыстальнік Mattermost {{.MattermostUsername}} не мае правоў запісу ў неабходны бінарны файл. Сістэмны адміністратар можа абнавіць дазволы файла, выканаўшы наступную каманду на серверы, дзе ўсталяваны Mattermost:\n\n```\nchown {{.MattermostUsername}} \"{{.Path}}\"\n```\n\nПасля змены дазволаў файла паспрабуйце зноў абнавіць Mattermost. Пасля абнаўлення і перазапуску не забудзьцеся аднавіць зыходныя дазволы для бінарнага файла:\n\n```\nchown {{.FileUsername}} \"{{.Path}}\"\n```" }, { "id": "api.upgrade_to_enterprise.invalid-user-and-permission.app_error", - "translation": "Mattermost не ўдалося абнавіць да Карпаратыўнай рэдакцыі. Сістэмны карыстач Mattermost {{.MattermostUsername}} не мае правоў на запіс у неабходны двайковы файл. Сістэмны адміністратар можа абнавіць правы доступу да файлаў, выканаўшы наступную каманду на серверы, дзе ўсталяваны Mattermost:\n\n```\nchown {{.MattermostUsername}} \"{{.Path}}\"\nchmod +w \"{{.Path}}\"\n```\n\nПасля змены правоў доступу да файла паспрабуйце зноў абнавіць Mattermost. Пасля абнаўлення і перазапуску не забудзьцеся аднавіць зыходныя дазволы для двайковага файла:\n\n```\nchown {{.FileUsername}} \"{{.Path}}\"\nchmod -w \"{{.Path}}\"\n```" + "translation": "Mattermost не атрымалася абнавіць да Enterprise Edition. Сістэмны карыстальнік Mattermost {{.MattermostUsername}} не мае правоў запісу ў неабходны бінарны файл. Сістэмны адміністратар можа абнавіць дазволы файла, выканаўшы наступную каманду на серверы, дзе ўсталяваны Mattermost:\n\n```\nchown {{.MattermostUsername}} \"{{.Path}}\"\nchmod +w \"{{.Path}}\"\n```\n\nПасля змены дазволаў файла паспрабуйце зноў абнавіць Mattermost. Пасля абнаўлення і перазапуску не забудзьцеся аднавіць зыходныя дазволы для бінарнага файла:\n\n```\nchown {{.FileUsername}} \"{{.Path}}\"\nchmod -w \"{{.Path}}\"\n```" }, { "id": "api.upgrade_to_enterprise.invalid-permission.app_error", - "translation": "Mattermost не ўдалося абнавіць да Карпаратыўнай рэдакцыі. Сістэмны карыстач Mattermost {{.MattermostUsername}} не мае правоў на запіс у неабходны двайковы файл. Сістэмны адміністратар можа абнавіць правы доступу да файлаў, выканаўшы наступную каманду на серверы, дзе ўсталяваны Mattermost:\n\n```\nchmod +w \"{{.Path}}\"\n```\n\nПасля змены правоў доступу да файла паспрабуйце зноў абнавіць Mattermost. Пасля абнаўлення і перазапуску не забудзьцеся аднавіць зыходныя дазволы для двайковага файла:\n\n```\nchmod -w \"{{.Path}}\"\n```" + "translation": "Mattermost не атрымалася абнавіць да Enterprise Edition. Сістэмны карыстальнік Mattermost {{.MattermostUsername}} не мае правоў запісу ў неабходны бінарны файл. Сістэмны адміністратар можа абнавіць дазволы файла, выканаўшы наступную каманду на серверы, дзе ўсталяваны Mattermost:\n\n```\nchmod +w \"{{.Path}}\"\n```\n\nПасля змены дазволаў файла паспрабуйце зноў абнавіць Mattermost. Пасля абнаўлення і перазапуску не забудзьцеся аднавіць зыходныя дазволы для бінарнага файла:\n\n```\nchmod -w \"{{.Path}}\"\n```" }, { "id": "api.upgrade_to_enterprise.generic_error.app_error", - "translation": "Mattermost не ўдалося абнавіць да Карпаратыўнай рэдакцыі." + "translation": "Mattermost не атрымалася абнавіць да Enterprise Edition." }, { "id": "api.upgrade_to_enterprise.app_error", - "translation": "Абнаўленне да Mattermost Карпаратыўная рэдакцыя ўжо выконваецца." + "translation": "Абнаўленне да Mattermost Enterprise Edition ужо выконваецца." }, { "id": "api.upgrade_to_enterprise.already-enterprise.app_error", - "translation": "Вы не можаце выканаць абнаўленне, таму што вы ўжо карыстаецеся Mattermost Карпаратыўная рэдакцыя." + "translation": "Вы не можаце абнавіцца, бо ў вас ужо працуе Mattermost Enterprise Edition." }, { "id": "api.upgrade_to_enterprise.already-done.app_error", - "translation": "Вы ўжо перайшлі на Mattermost Карпаратыўная рэдакцыя. Калі ласка, перазапусціце сервер, каб завяршыць абнаўленне." + "translation": "Вы ўжо абнавіліся да Mattermost Enterprise Edition. Калі ласка, перазапусціце сервер, каб завяршыць абнаўленне." }, { "id": "api.unable_to_create_zip_file", @@ -5293,179 +5293,179 @@ }, { "id": "api.templates.welcome_body.title", - "translation": "Сардэчна запрашаем у каманду" + "translation": "Вітаем у камандзе" }, { "id": "api.templates.welcome_body.subTitle2", - "translation": "Пстрыкніце ніжэй, каб пацвердзіць ваш email-адрас." + "translation": "Націсніце ніжэй, каб пацвердзіць свой адрас электроннай пошты." }, { "id": "api.templates.welcome_body.info1", - "translation": "Калі гэта былі не вы, вы можаце смела ігнараваць гэтае паведамленне." + "translation": "Калі гэта былі не вы, вы можаце смела ігнараваць гэты ліст." }, { "id": "api.templates.welcome_body.info", - "translation": "Гэты email-адрас быў выкарыстаны для стварэння ўліковага запісу Mattermost." + "translation": "Гэты адрас электроннай пошты быў выкарыстаны для стварэння ўліковага запісу на Mattermost." }, { "id": "api.templates.welcome_body.app_download_title", - "translation": "Спампаваць настольныя і мабільныя прыкладанні" + "translation": "Спампаваць працоўны стол і мабільныя праграмы" }, { "id": "api.templates.welcome_body.app_download_info", - "translation": "Для зручнасці, Вы можаце спампаваць прыкладанні для PC, Mac, iOS and Android." + "translation": "Для найлепшага досведу спампуйце праграмы для ПК, Mac, iOS і Android." }, { "id": "api.templates.verify_subject", - "translation": "[{{ .SiteName }}] Пацвярджэнне email" + "translation": "[{{ .SiteName }}] Пацвярджэнне электроннай пошты" }, { "id": "api.templates.verify_body.title", - "translation": "Пацвердзіць email-адрас" + "translation": "Пацвердзіце свой адрас электроннай пошты" }, { "id": "api.templates.verify_body.subTitle2", - "translation": "Пстрыкніце ніжэй, каб пацвердзіць ваш email-адрас." + "translation": "Націсніце ніжэй, каб пацвердзіць свой адрас электроннай пошты." }, { "id": "app.plugin.signature_decode.app_error", - "translation": "Не атрымалася дэкадаваць сігнатуру base64." + "translation": "Немагчыма дэкадаваць подпіс base64." }, { "id": "app.plugin.restart.app_error", - "translation": "Не ўдалося перазапусціць убудову падчас абнаўлення." + "translation": "Немагчыма перазапусціць плагін пры абнаўленні." }, { "id": "app.plugin.remove_bundle.app_error", - "translation": "Не атрымалася выдаліць плягін з файлавага сховішча." + "translation": "Немагчыма выдаліць пакет плагіна з файлавага сховішча." }, { "id": "app.plugin.remove.app_error", - "translation": "Немагчыма выдаліць убудову." + "translation": "Немагчыма выдаліць плагін." }, { "id": "app.plugin.not_installed.app_error", - "translation": "Убудова не ўсталявана." + "translation": "Плагін не ўсталяваны." }, { "id": "app.plugin.mvdir.app_error", - "translation": "Немагчыма перамясціць убудову з часовага каталога ў канчатковы пункт прызначэння. Іншы плягін можа выкарыстоўваць тое ж імя каталога." + "translation": "Немагчыма перамясціць плагін з часовай тэчкі ў канчатковае месца прызначэння. Іншы плагін можа выкарыстоўваць тую ж назву тэчкі." }, { "id": "app.plugin.modify_saml.app_error", - "translation": "Не ўдалося мадыфікаваць файлы saml." + "translation": "Немагчыма змяніць файлы saml." }, { "id": "app.plugin.marshal.app_error", - "translation": "Не атрымалася вырабіць маршаляванне плагінаў з крамы плагінаў." + "translation": "Не атрымалася апрацаваць плагіны з крамы плагінаў." }, { "id": "app.plugin.marketplace_plugins.signature_not_found.app_error", - "translation": "Не атрымалася знайсці сігнатуру запытанага плагіна з крамы плагінаў." + "translation": "Немагчыма знайсці подпіс запытанага плагіна з крамы плагінаў." }, { "id": "app.plugin.marketplace_plugins.not_found.app_error", - "translation": "Не атрымалася знайсці запытаны плагін з крамы плагінаў." + "translation": "Немагчыма знайсці запытаны плагін з крамы плагінаў." }, { "id": "app.plugin.marketplace_plugin_request.app_error", - "translation": "Не атрымалася дэкадаваць запыт ад крамы плагінаў." + "translation": "Не атрымалася дэкадаваць запыт да крамы плагінаў." }, { "id": "app.plugin.marketplace_disabled.app_error", - "translation": "Крама плагінаў была адключаная. Для падрабязнай інфармацыі гл. логі." + "translation": "Крама плагінаў адключана. Калі ласка, праверце свае лагі для падрабязнасцей." }, { "id": "app.plugin.marketplace_client.failed_to_fetch", - "translation": "Не атрымалася атрымаць убудовы з сервера крамы убудоў." + "translation": "Не атрымалася атрымаць плагіны з сервера крамы плагінаў." }, { "id": "app.plugin.marketplace_client.app_error", - "translation": "Не атрымалася стварыць кліента для крамы плагінаў." + "translation": "Не атрымалася стварыць кліент крамы плагінаў." }, { "id": "app.plugin.manifest.app_error", - "translation": "Немагчыма знайсці маніфест для вынятага плагіна." + "translation": "Немагчыма знайсці маніфест для распакаванага плагіна." }, { "id": "app.plugin.invalid_version.app_error", - "translation": "Немагчыма разабраць версію плагіна." + "translation": "Версію плагіна не атрымалася прааналізаваць." }, { "id": "app.plugin.invalid_id.app_error", - "translation": "Ідэнтыфікатар плагіна павінен змяшчаць не менш {{.Min}} сімвалаў, не больш за {{.Max}} сімвалаў і адпавядаць {{.Regex}}." + "translation": "Ідэнтыфікатар плагіна павінен мець даўжыню ад {{.Min}} да {{.Max}} сімвалаў і адпавядаць {{.Regex}}." }, { "id": "app.plugin.install_marketplace_plugin.app_error", - "translation": "Не атрымалася ўсталяваць убудову з крамы плагінаў." + "translation": "Не атрымалася ўсталяваць плагін з крамы плагінаў." }, { "id": "app.plugin.install_id_failed_remove.app_error", - "translation": "Немагчыма ўсталяваць убудову. Убудова з тым жа ідэнтыфікатарам ужо ўсталяваны і не можа быць выдалены." + "translation": "Немагчыма ўсталяваць плагін. Плагін з такім жа ідэнтыфікатарам ужо ўсталяваны і яго не ўдалося выдаліць." }, { "id": "app.plugin.install_id.app_error", - "translation": "Немагчыма ўсталяваць убудову. Убудова з такім жа ідэнтыфікатарам ужо ўсталяваны." + "translation": "Немагчыма ўсталяваць плагін. Плагін з такім жа ідэнтыфікатарам ужо ўсталяваны." }, { "id": "app.plugin.install.app_error", - "translation": "Немагчыма ўсталяваць убудову." + "translation": "Немагчыма ўсталяваць плагін." }, { "id": "app.plugin.get_statuses.app_error", - "translation": "Немагчыма атрымаць статуты плагінаў." + "translation": "Немагчыма атрымаць статусы плагінаў." }, { "id": "app.plugin.get_public_key.get_file.app_error", - "translation": "Адбылася памылка пры атрыманні адчыненага ключа са сховішча." + "translation": "Памылка пры атрыманні адкрытага ключа са сховішча." }, { "id": "app.plugin.get_plugins.app_error", - "translation": "Не атрымалася атрымаць актыўныя плагіны." + "translation": "Немагчыма атрымаць актыўныя плагіны." }, { "id": "app.plugin.get_cluster_plugin_statuses.app_error", - "translation": "Немагчыма атрымаць статуты плагінаў з кластара." + "translation": "Немагчыма атрымаць статусы плагінаў з кластара." }, { "id": "app.plugin.extract.app_error", - "translation": "Выяўлена памылка пры выманні камплекта плагіна." + "translation": "Памылка пры распакаванні архіва плагіна." }, { "id": "app.plugin.disabled.app_error", - "translation": "Убудовы былі адключаныя. Калі ласка, праверце вашыя часопісы для падрабязнасьцяў." + "translation": "Плагіны былі адключаны. Калі ласка, праверце свае лагі для падрабязнасцей." }, { "id": "app.plugin.delete_public_key.delete.app_error", - "translation": "Узнікла памылка пры выдаленні адчыненага ключа." + "translation": "Памылка пры выдаленні адкрытага ключа." }, { "id": "app.plugin.deactivate.app_error", - "translation": "Не атрымалася адключыць убудову." + "translation": "Немагчыма дэактываваць плагін." }, { "id": "app.plugin.config.app_error", - "translation": "Памылка захавання стану плагіна ў канфігу." + "translation": "Памылка захавання стану плагіна ў канфігурацыі." }, { "id": "app.plugin.cluster.save_config.app_error", - "translation": "Канфігурацыя плагіна ў файле config.json павінна абнаўляцца ўручную пры выкарыстанні ReadOnlyConfig з уключанай кластэрызацыяй." + "translation": "Канфігурацыю плагіна ў вашым файле config.json трэба абнаўляць уручную пры выкарыстанні ReadOnlyConfig з уключаным кластарам." }, { "id": "app.oauth.update_app.updating.app_error", - "translation": "Мы сутыкнуліся з памылкай пры абнаўленні дадатку." + "translation": "Памылка пры абнаўленні праграмы." }, { "id": "app.oauth.update_app.find.app_error", - "translation": "Немагчыма знайсці існуючае прыкладанне для абнаўлення." + "translation": "Немагчыма знайсці існуючую праграму для абнаўлення." }, { "id": "app.oauth.save_app.save.app_error", - "translation": "Немагчыма захаваць дадатак." + "translation": "Немагчыма захаваць праграму." }, { "id": "app.oauth.save_app.existing.app_error", - "translation": "Неабходна выклікаць абнаўленне для існуючага дадатку." + "translation": "Трэба выклікаць абнаўленне для існуючай праграмы." }, { "id": "app.oauth.remove_access_data.app_error", @@ -5477,51 +5477,51 @@ }, { "id": "app.oauth.get_apps.find.app_error", - "translation": "Адбылася памылка пры пошуку прыкладанняў OAuth2." + "translation": "Памылка пры пошуку праграм OAuth2." }, { "id": "app.oauth.get_app_by_user.find.app_error", - "translation": "Мы не змаглі знайсці якія-небудзь існуючыя прыкладанні." + "translation": "Немагчыма знайсці існуючыя праграмы." }, { "id": "app.oauth.get_app.finding.app_error", - "translation": "Мы знайшлі памылку пры пошуку прыкладання." + "translation": "Памылка пры пошуку праграмы." }, { "id": "app.oauth.get_app.find.app_error", - "translation": "Немагчыма знайсці апытанае прыкладанне." + "translation": "Немагчыма знайсці запытаную праграму." }, { "id": "app.oauth.get_access_data_by_user_for_app.app_error", - "translation": "Мы сутыкнуліся з памылкай пры пошуку ўсіх токенаў доступу." + "translation": "Памылка пры пошуку ўсіх токенаў доступу." }, { "id": "app.oauth.delete_app.app_error", - "translation": "Адбылася памылка пры выдаленні прыкладання OAuth2." + "translation": "Памылка пры выдаленні праграмы OAuth2." }, { "id": "app.notification.subject.notification.full", - "translation": "[{{ .SiteName }}] Апавяшчэнне ў {{ .TeamName}} у {{.Month}} {{.Day}}, {{.Year}}" + "translation": "[{{ .SiteName }}] Апавяшчэнне ў {{ .TeamName}} {{.Month}} {{.Day}}, {{.Year}}" }, { "id": "app.notification.subject.group_message.generic", - "translation": "[{{.SiteName}}] Новае групавое паведамленне ў {{.Day}} {{.Month}}, {{.Year}}" + "translation": "[{{ .SiteName }}] Новае групавое паведамленне {{.Month}} {{.Day}}, {{.Year}}" }, { "id": "app.notification.subject.group_message.full", - "translation": "[{{ .SiteName }}] Новае групавое паведамленне на {{ .ChannelName}} у {{.Month}} {{.Day}}, {{.Year}}" + "translation": "[{{ .SiteName }}] Новае групавое паведамленне ў {{ .ChannelName}} {{.Month}} {{.Day}}, {{.Year}}" }, { "id": "app.notification.subject.direct.full", - "translation": "[{{.SiteName}}] Новае асабістае паведамленне ад @{{.SenderDisplayName}} у {{.Month}} {{.Day}}, {{.Year}}" + "translation": "[{{.SiteName}}] Новае прамое паведамленне ад {{.SenderDisplayName}} {{.Month}} {{.Day}}, {{.Year}}" }, { "id": "app.notification.footer.title", - "translation": "Жадаеце змяніць параметры апавяшчэнняў?" + "translation": "Хочаце змяніць налады апавяшчэнняў?" }, { "id": "app.notification.footer.info", - "translation": " і перайдзіце ў Параметры > Апавяшчэнні" + "translation": " і перайдзіце ў Налады > Апавяшчэнні" }, { "id": "api.templates.verify_body.info1", @@ -5529,71 +5529,71 @@ }, { "id": "api.templates.verify_body.info", - "translation": "Гэты адрас электроннай пошты быў выкарыстаны для стварэння ўліковага запісу Mattermost." + "translation": "Гэты адрас электроннай пошты быў выкарыстаны для стварэння ўліковага запісу на Mattermost." }, { "id": "api.templates.username_change_subject", - "translation": "[{{ .SiteName }}] Ваша імя карыстальніка было зменена" + "translation": "[{{ .SiteName }}] Вашае імя карыстальніка змянілася" }, { "id": "api.templates.username_change_body.title", - "translation": "Вы абнавілі ваша імя карыстальніка" + "translation": "Вы абнавілі сваё імя карыстальніка" }, { "id": "api.templates.username_change_body.info", - "translation": "Ваш e-mail адрас для {{.TeamDisplayName}} быў зменены на {{.NewEmail}}." + "translation": "Ваша імя карыстальніка для {{.TeamDisplayName}} было зменены на {{.NewUsername}}." }, { "id": "api.templates.user_access_token_subject", - "translation": "[{{ .SiteName }}] Персанальны токен доступу дададзены да вашага акаўнта" + "translation": "[{{ .SiteName }}] Асабісты токен доступу дададзены да вашага ўліковага запісу" }, { "id": "api.templates.user_access_token_body.title", - "translation": "Асабісты ключ доступу, дададзены ў ваш уліковы запіс" + "translation": "Асабісты токен доступу дададзены да вашага ўліковага запісу" }, { "id": "api.templates.user_access_token_body.info", - "translation": "У ваш рахунак дададзены токен доступу для {{.SiteURL}}. Яго можна выкарыстоўваць для доступу да {{.SiteName}} з вашым уліковым запісам." + "translation": "Асабісты токен доступу быў дададзены да вашага ўліковага запісу на {{ .SiteURL }}. Яго можна выкарыстоўваць для доступу да {{.SiteName}} з вашым уліковым запісам." }, { "id": "api.templates.signin_change_email.subject", - "translation": "[{{ .SiteName }}] Ваш метад уваходу абноўлены" + "translation": "[{{ .SiteName }}] Ваш метад уваходу быў абноўлены" }, { "id": "api.templates.signin_change_email.body.title", - "translation": "Вы абнавілі свой спосаб уваходу" + "translation": "Вы абнавілі свой метад уваходу" }, { "id": "api.templates.signin_change_email.body.info", - "translation": "Вы змянілі свой метад уваходу для {{.SiteName}} на {{.Method}}." + "translation": "Вы абнавілі свой метад уваходу ў {{ .SiteName }} на {{.Method}}." }, { "id": "api.templates.reset_subject", - "translation": "[{{ .SiteName }}] Скінуць пароль" + "translation": "[{{ .SiteName }}] Скінуць ваш пароль" }, { "id": "api.templates.reset_body.subTitle", - "translation": "Націсніце на кнопку ніжэй, каб скінуць пароль. Калі вы не запытвалі яго, вы можаце бяспечна праігнараваць гэты ліст." + "translation": "Націсніце кнопку ніжэй, каб скінуць пароль. Калі вы не запытвалі гэтага, вы можаце бяспечна праігнараваць гэтае электроннае пісьмо." }, { "id": "api.templates.reset_body.info", - "translation": "Тэрмін дзеяння спасылкі скіду пароля заканчваецца праз 24 гадзіны." + "translation": "Спасылка для скід пароля дзейнічае 24 гадзіны." }, { "id": "api.templates.remove_expired_license.subject", - "translation": "Карпаратыўная ліцэнзія Mattermost была адключаная." + "translation": "Ліцэнзія Mattermost Enterprise была адключана." }, { "id": "api.templates.remove_expired_license.body.title", - "translation": "Тэрмін дзеяння карпаратыўнай ліцэнзіі скончыўся, і некаторыя функцыі могуць быць адключаныя. Калі ласка, абнавіце ліцэнзію." + "translation": "Тэрмін дзеяння вашай ліцэнзіі Enterprise Edition скончыўся, і некаторыя функцыі могуць быць адключаны. Калі ласка, падоўжыце ліцэнзію." }, { "id": "api.templates.questions_footer.info", - "translation": "Напішыце нам у любы час на email " + "translation": "Патрэбна дапамога ці ёсць пытанні? Напішыце нам па адрасе " }, { "id": "api.templates.password_change_subject", - "translation": "[{{ .SiteName }}] Ваш пароль абноўлены" + "translation": "[{{ .SiteName }}] Ваш пароль быў абноўлены" }, { "id": "api.templates.password_change_body.title", @@ -5601,15 +5601,15 @@ }, { "id": "api.templates.password_change_body.info", - "translation": "Ваш пароль быў абноўлены для {{.TeamDisplayName}} у {{.TeamURL}} {{.Method}}." + "translation": "Ваш пароль быў абноўлены для {{.TeamDisplayName}} на {{ .TeamURL }} праз {{.Method}}." }, { "id": "api.templates.mfa_deactivated_body.title", - "translation": "Шматфактарная праверка сапраўднасці адключана" + "translation": "Шматфактарная аўтэнтыфікацыя была выдалена" }, { "id": "api.templates.mfa_deactivated_body.info", - "translation": "Шматфактарная аўтэнтыфікацыя была выдалена з вашага ўліковага запісу на {{.SiteURL}}." + "translation": "Шматфактарная аўтэнтыфікацыя была выдалена з вашага ўліковага запісу на {{ .SiteURL }}." }, { "id": "api.templates.mfa_change_subject", @@ -5617,39 +5617,39 @@ }, { "id": "api.templates.mfa_activated_body.title", - "translation": "Шматфактарная праверка сапраўднасці актывавана" + "translation": "Шматфактарная аўтэнтыфікацыя была дададзена" }, { "id": "api.templates.mfa_activated_body.info", - "translation": "Шматфактарная аўтэнтыфікацыя была дададзена ў ваш уліковы запіс на {{.SiteURL}}." + "translation": "Шматфактарная аўтэнтыфікацыя была дададзена да вашага ўліковага запісу на {{ .SiteURL }}." }, { "id": "api.templates.license_up_for_renewal_title", - "translation": "Ваша падпіска Mattermost падлягае падаўжэнню" + "translation": "Ваша падпіска Mattermost падлягае падоўжэнню" }, { "id": "api.templates.license_up_for_renewal_subtitle", - "translation": "{{.UserName}}, тэрмін дзеяння Вашай падпіскі заканчваецца праз {{.Days}} дзён. Мы спадзяемся, што вам падабаецца гнуткае і бяспечнае каманднае супрацоўніцтва, якое забяспечвае Mattermost. Падоўжыце падпіску хуткім часам, каб Ваша каманда магла працягваць карыстацца гэтымі перавагамі." + "translation": "{{.UserName}}, тэрмін дзеяння вашай падпіскі заканчваецца праз {{.Days}} дзён. Мы спадзяемся, што вы адчуваеце гнуткае, бяспечнае супрацоўніцтва ў камандзе, якое дазваляе Mattermost. Падоўжыце хутка, каб ваша каманда магла працягваць карыстацца гэтымі перавагамі." }, { "id": "api.templates.license_up_for_renewal_subject", - "translation": "Ваша ліцэнзія падлягае падаўжэнню" + "translation": "Ваша ліцэнзія падлягае падоўжэнню" }, { "id": "api.templates.invite_subject", - "translation": "[{{ .SiteName }}] {{ .SenderName }} запрасіў вас у каманду {{ .TeamDisplayName }}" + "translation": "[{{ .SiteName }}] {{ .SenderName }} запрасіў вас далучыцца да каманды {{ .TeamDisplayName }}" }, { "id": "api.templates.invite_guest_subject", - "translation": "[{{ .SiteName }}] {{ .SenderName }} запрасіў вас у каманду {{ .TeamDisplayName }} у якасці госця" + "translation": "[{{ .SiteName }}] {{ .SenderName }} запрасіў вас далучыцца да каманды {{ .TeamDisplayName }} як госць" }, { "id": "api.templates.invite_body_guest.subTitle", - "translation": "Вы былі запрошаны ў якасці госця ў каманду" + "translation": "Вы былі запрошаны як госць для супрацоўніцтва з камандай" }, { "id": "api.templates.invite_body_footer.info", - "translation": "Mattermost – гэта гнуткая платформа абмену паведамленнямі з адкрытым зыходным кодам, якая забяспечвае бяспечную сумесную працу ў камандзе." + "translation": "Mattermost - гэта гнуткая платформа абмену паведамленнямі з адкрытым зыходным кодам, якая забяспечвае бяспечнае супрацоўніцтва ў камандзе." }, { "id": "api.templates.invite_body.title", @@ -5657,59 +5657,59 @@ }, { "id": "api.templates.invite_body.subTitle", - "translation": "Пачніце працу з вашай камандай на Mattermost" + "translation": "Пачніце супрацоўніцтва з вашай камандай на Mattermost" }, { "id": "api.templates.email_warning", - "translation": "Калі вы не ўносілі гэтыя змены, звернецеся да сістэмнага адміністратара." + "translation": "Калі вы не рабілі гэтую змену, звярніцеся да сістэмнага адміністратара." }, { "id": "api.templates.email_us_anytime_at", - "translation": "Напішыце нам у любы час на email " + "translation": "Напішыце нам у любы час па адрасе " }, { "id": "api.templates.email_info1", - "translation": "Па любых пытаннях, пішыце нам у любы час: " + "translation": "Ёсць пытанні? Напішыце нам у любы час: " }, { "id": "api.templates.email_footer_v2", - "translation": "© 2022 Mattermost, Inc. 530 Lytton Avenue, Second floor, Palo Alto, CA, 94301" + "translation": "© 2015 - {{ .CurrentYear }} Mattermost, Inc. 2100 Geng Road, Suite 210, Palo Alto, CA, 94303" }, { "id": "api.templates.email_footer", - "translation": "Каб змяніць налады апавяшчэнняў, увайдзіце ў сваю каманду і перайдзіце ў Параметры > Апавяшчэнні." + "translation": "Каб змяніць налады апавяшчэнняў, увайдзіце на сайт вашай каманды і перайдзіце ў Налады > Апавяшчэнні." }, { "id": "api.templates.email_change_verify_subject", - "translation": "[{{ .SiteName }}] Пацвердзіць новы email" + "translation": "[{{ .SiteName }}] Пацвердзіце новы адрас электроннай пошты" }, { "id": "api.templates.email_change_verify_body.title", - "translation": "Вы абнавілі свой email" + "translation": "Вы паспяхова абнавілі сваю электронную пошту" }, { "id": "api.templates.email_change_verify_body.info", - "translation": "Для завяршэння абнаўлення Вашага адраса электроннай пошты для {{.TeamDisplayName}}, калі ласка, націсніце на спасылку ніжэй, каб пацвердзіць карэктнасць адраса." + "translation": "Каб завяршыць абнаўленне вашага адраса электроннай пошты для {{.TeamDisplayName}}, націсніце на спасылку ніжэй, каб пацвердзіць, што гэта правільны адрас." }, { "id": "api.templates.email_change_subject", - "translation": "[{{ .SiteName }}] Ваш email быў зменены" + "translation": "[{{ .SiteName }}] Ваш адрас электроннай пошты быў зменены" }, { "id": "api.templates.email_change_body.title", - "translation": "Вы абнавілі свой email" + "translation": "Вы абнавілі сваю электронную пошту" }, { "id": "api.templates.email_change_body.info", - "translation": "Ваш e-mail адрас для {{.TeamDisplayName}} быў зменены на {{.NewEmail}}." + "translation": "Ваш адрас электроннай пошты для {{.TeamDisplayName}} быў зменены на {{.NewEmail}}." }, { "id": "api.templates.deactivate_subject", - "translation": "[{{ .SiteName }}] Ваш рахунак на {{ .ServerURL }} быў дэактываваны" + "translation": "[{{ .SiteName }}] Ваш уліковы запіс на {{ .ServerURL }} быў дэактываваны" }, { "id": "api.templates.deactivate_body.warning", - "translation": "Калі гэтая змена не была ініцыяваная вамі ці вы жадаеце паўторна актываваць свой уліковы запіс, звярніцеся да сістэмнага адміністратара." + "translation": "Калі гэтая змена не была ініцыявана вамі або вы хочаце паўторна актываваць свой уліковы запіс, звярніцеся да вашага сістэмнага адміністратара." }, { "id": "api.server.start_server.starting.critical", @@ -5721,43 +5721,43 @@ }, { "id": "api.server.start_server.rate_limiting_memory_store", - "translation": "Не ўдалося ініцыялізаваць абмежаванне памяці. Праверце параметр MemoryStoreSize у наладах." + "translation": "Немагчыма ініцыялізаваць сховішча памяці для абмежавання хуткасці. Праверце наладу MemoryStoreSize у канфігурацыі." }, { "id": "api.server.start_server.forward80to443.enabled_but_listening_on_wrong_port", - "translation": "Не ўдаецца пераправіць порт 80 на порт 443 падчас праслухоўвання порта %s: адключыце пераадрасацыю порта 80 на 443 пры выкарыстанні проксі-сервера" + "translation": "Немагчыма пераадрасаваць порт 80 на порт 443 пры праслухоўванні на порце %s: адключыце Forward80To443, калі выкарыстоўваецца проксі-сервер" }, { "id": "api.server.start_server.forward80to443.disabled_while_using_lets_encrypt", - "translation": "Неабходна перанакіроўваць 80 на 443 пры выкарыстанні LetsEncrypt" + "translation": "Неабходна ўключыць Forward80To443 пры выкарыстанні LetsEncrypt" }, { "id": "api.server.license_up_for_renewal.error_sending_email", - "translation": "Не ўдалося адправіць ліцэнзію для абнаўленняў па электроннай пошце" + "translation": "Не ўдалося адправіць ліцэнзію для паведамленняў аб падоўжэнні" }, { "id": "api.scheme.patch_scheme.license.error", - "translation": "Ваша ліцэнзія не падтрымлівае змену схем дазволу" + "translation": "Ваша ліцэнзія не падтрымлівае абнаўленне схем дазволаў" }, { "id": "api.scheme.get_teams_for_scheme.scope.error", - "translation": "Немагчыма атрымаць каманды для схемы, бо дадзеныя схемы не з'яўляюцца схемамі каманд." + "translation": "Немагчыма атрымаць каманды для схемы, бо прадастаўленая схема не з'яўляецца каманднай." }, { "id": "api.scheme.get_channels_for_scheme.scope.error", - "translation": "Немагчыма атрымаць каналы для схемы, бо прадстаўленыя схемы не з'яўляюцца схемамі каналаў." + "translation": "Немагчыма атрымаць каналы для схемы, бо прадастаўленая схема не з'яўляецца схемай каналаў." }, { "id": "api.scheme.delete_scheme.license.error", - "translation": "Ваша ліцэнзія не падтрымлівае выдаленне схем дазволу" + "translation": "Ваша ліцэнзія не падтрымлівае выдаленне схем дазволаў" }, { "id": "api.scheme.create_scheme.license.error", - "translation": "Ваша ліцэнзія не падтрымлівае стварэнне схем дазволу." + "translation": "Ваша ліцэнзія не падтрымлівае стварэнне схем дазволаў." }, { "id": "api.roles.patch_roles.not_allowed_permission.error", - "translation": "Адно ці некалькі з дазволаў, якія вы спрабуеце дадаць ці выдаліць, не дадзена" + "translation": "Адзін або некалькі з наступных дазволаў, якія вы спрабуеце дадаць або выдаліць, не дазволены" }, { "id": "api.roles.patch_roles.license.error", @@ -5769,7 +5769,7 @@ }, { "id": "api.remote_cluster.update_not_unique.app_error", - "translation": "Бяспечнае злучэнне з такім URL ужо існуе." + "translation": "Бяспечнае злучэнне з тым жа URL ужо існуе." }, { "id": "api.remote_cluster.update.app_error", @@ -5777,7 +5777,7 @@ }, { "id": "api.remote_cluster.service_not_enabled.app_error", - "translation": "Служба выдаленага кластара не ўключана." + "translation": "Служба аддаленага кластара не ўключана." }, { "id": "api.remote_cluster.save_not_unique.app_error", @@ -5785,7 +5785,7 @@ }, { "id": "api.remote_cluster.save.app_error", - "translation": "Адбылася памылка пры захаванні бяспечнага злучэння." + "translation": "Мы сутыкнуліся з памылкай пры захаванні бяспечнага злучэння." }, { "id": "api.remote_cluster.get.app_error", @@ -5793,7 +5793,7 @@ }, { "id": "api.remote_cluster.delete.app_error", - "translation": "Узнікла памылка выдалення бяспечнага злучэння." + "translation": "Мы сутыкнуліся з памылкай пры выдаленні бяспечнага злучэння." }, { "id": "api.reaction.save_reaction.user_id.app_error", @@ -5801,35 +5801,35 @@ }, { "id": "api.reaction.save_reaction.invalid.app_error", - "translation": "Рэакцыя невалідная." + "translation": "Рэакцыя несапраўдная." }, { "id": "api.reaction.save.archived_channel.app_error", - "translation": "Вы не можаце рэагаваць у архіўным канале." + "translation": "Вы не можаце рэагаваць у архіваваным канале." }, { "id": "api.reaction.delete.archived_channel.app_error", - "translation": "Вы не можаце выдаліць рэакцыю ў архіўным канале." + "translation": "Вы не можаце выдаліць рэакцыю ў архіваваным канале." }, { "id": "api.push_notifications_ack.message.parse.app_error", - "translation": "Адбылася памылка падчас пабудовы пацвярджальнага паведамлення push-паведамлення." + "translation": "Памылка пры стварэнні паведамлення пацвярджэння push-апавяшчэння." }, { "id": "api.push_notifications_ack.forward.app_error", - "translation": "Адбылася памылка пры адпраўцы паведамлення ў службу push-паведамленняў." + "translation": "Памылка пры адпраўцы пацвярджэння дастаўкі ў службу push-апавяшчэнняў." }, { "id": "api.push_notifications.session.expired", - "translation": "Час сеансу скончыўся: увайдзіце ў сістэму, каб працягнуць атрымліваць апавяшчэнні. Сеансы для {{.siteName}} настроены вашым сістэмным адміністратарам так, што тэрмін іх дзеяння заканчваецца кожныя {{.daysCount}} дзён." + "translation": "Сесія скончылася: калі ласка, увайдзіце, каб працягваць атрымліваць апавяшчэнні. Сесіі для {{.siteName}} настроены вашым сістэмным адміністратарам так, што яны заканчваюцца кожныя {{.hoursCount}} гадзін(ы)." }, { "id": "api.push_notifications.message.parse.app_error", - "translation": "Адбылася памылка падчас пабудовы паведамлення push-паведамлення." + "translation": "Памылка пры стварэнні паведамлення push-апавяшчэння." }, { "id": "api.push_notification.id_loaded.fetch.app_error", - "translation": "Адбылася памылка пры атрыманні push-паведамлення з усталяваным ідэнтыфікатарам (ID Loaded)." + "translation": "Памылка пры атрыманні push-апавяшчэння па ID." }, { "id": "api.push_notification.id_loaded.default_message", @@ -5837,11 +5837,11 @@ }, { "id": "api.push_notification.disabled.app_error", - "translation": "Push апавяшчэння адключаныя на гэтым серверы." + "translation": "Push-апавяшчэнні адключаны на гэтым серверы." }, { "id": "api.preference.update_preferences.update_sidebar.app_error", - "translation": "Немагчыма абнавіць бакавую панэль, каб адпавядаць абноўленым наладам" + "translation": "Немагчыма абнавіць бакавую панэль, каб яна адпавядала абноўленым наладам." }, { "id": "api.preference.update_preferences.set.app_error", @@ -5849,31 +5849,31 @@ }, { "id": "api.preference.preferences_category.get.app_error", - "translation": "Немагчыма атрымаць наладкі карыстальніка." + "translation": "Немагчыма атрымаць налады карыстальніка." }, { "id": "api.preference.delete_preferences.update_sidebar.app_error", - "translation": "Немагчыма абнавіць бакавую панэль, каб адпавядаць выдаленым наладам" + "translation": "Немагчыма абнавіць бакавую панэль, каб яна адпавядала выдаленым наладам." }, { "id": "api.preference.delete_preferences.delete.app_error", - "translation": "Немагчыма выдаліць наладкі карыстальніка." + "translation": "Немагчыма выдаліць налады карыстальніка." }, { "id": "api.post_get_post_by_id.get.app_error", - "translation": "Не ўдалося атрымаць паведамленне." + "translation": "Немагчыма атрымаць пост." }, { "id": "api.post.update_post.system_message.app_error", - "translation": "Не ўдалося абнавіць сістэмнае паведамленне." + "translation": "Немагчыма абнавіць сістэмнае паведамленне." }, { "id": "api.post.update_post.permissions_time_limit.app_error", - "translation": "Рэдагаванне паведамленняў дазволена толькі {{.timeLimit}} секунд пасля публікацыі. За падрабязнасцямі звярніцеся да Вашага сістэмнага адміністратара." + "translation": "Рэдагаванне паведамленняў дазволена толькі {{.timeLimit}} секунд. Звярніцеся да вашага сістэмнага адміністратара для атрымання падрабязнасцей." }, { "id": "api.post.update_post.find.app_error", - "translation": "Не ўдалося знайсці існуючае паведамленне або каментар для абнаўлення." + "translation": "Немагчыма знайсці існуючы пост або каментар для абнаўлення." }, { "id": "api.post.update_post.can_not_update_post_in_deleted.error", @@ -5881,11 +5881,11 @@ }, { "id": "api.post.send_notifications_and_forget.push_message", - "translation": "адправіў вам паведамленне." + "translation": "даслаў вам паведамленне." }, { "id": "api.post.send_notification_and_forget.push_comment_on_thread", - "translation": " пракаментаваў нітку з вашым удзелам." + "translation": " пракаментаваў гілку, у якой вы ўдзельнічалі." }, { "id": "api.post.send_notification_and_forget.push_comment_on_post", @@ -5893,15 +5893,15 @@ }, { "id": "api.post.send_notification_and_forget.push_comment_on_crt_thread", - "translation": " адказаў у чаце, на які Вы падпісаны." + "translation": " адказаў у гілцы, за якой вы сочыце." }, { "id": "api.post.search_posts.invalid_body.app_error", - "translation": "Немагчыма прааналізаваць цела запыту." + "translation": "Немагчыма разабраць цела запыту." }, { "id": "api.post.search_files.invalid_body.app_error", - "translation": "Не ўдалося разабраць цела запыту." + "translation": "Немагчыма разабраць цела запыту." }, { "id": "api.post.patch_post.can_not_update_post_in_deleted.error", @@ -5925,55 +5925,55 @@ }, { "id": "api.post.error_get_post_id.pending", - "translation": "Немагчыма атрымаць паведамленне, якое чакае." + "translation": "Немагчыма атрымаць чакаючае паведамленне." }, { "id": "api.post.disabled_here", - "translation": "Згадка @here была адключаная, паколькі колькасць карыстальнікаў на канале перавышае {{.Users}}." + "translation": "Апавяшчэнні @here адключаны ў каналах з больш чым {{.Users}} карыстальнікамі. Звярніцеся да вашага сістэмнага адміністратара для атрымання дадатковай інфармацыі." }, { "id": "api.post.disabled_channel", - "translation": "@channel быў адключаны, паколькі колькасць карыстальнікаў перавышае {{.Users}}." + "translation": "Апавяшчэнні @channel адключаны ў каналах з больш чым {{.Users}} карыстальнікамі. Звярніцеся да вашага сістэмнага адміністратара для атрымання дадатковай інфармацыі." }, { "id": "api.post.disabled_all", - "translation": "Згадка @all была адключаная, паколькі колькасць карыстальнікаў на канале перавышае {{.Users}}." + "translation": "Апавяшчэнні @all адключаны ў каналах з больш чым {{.Users}} карыстальнікамі. Звярніцеся да вашага сістэмнага адміністратара для атрымання дадатковай інфармацыі." }, { "id": "api.post.delete_post.can_not_delete_post_in_deleted.error", - "translation": "Нельга выдаліць пост у выдаленым канале." + "translation": "Нельга выдаліць паведамленне ў выдаленым канале." }, { "id": "api.post.deduplicate_create_post.pending", - "translation": "Адпраўка паведамлення адхілена, т.я. іншы кліент зрабіў такі ж запыт." + "translation": "Адхілена паведамленне, бо іншы кліент робіць такі ж запыт." }, { "id": "api.post.deduplicate_create_post.failed_to_get", - "translation": "Не ўдалося атрымаць арыгінальнае паведамленне. Кліент стварыў дублюючы запыт." + "translation": "Немагчыма атрымаць арыгінальнае паведамленне пасля дэдуплікацыі паўтараючагася запыту кліента." }, { "id": "api.post.create_post.channel_root_id.app_error", - "translation": "Няверны ChannelId для параметра RootId." + "translation": "Несапраўдны ChannelId для параметру RootId." }, { "id": "api.post.create_post.can_not_post_to_deleted.error", - "translation": "Немагчыма стварыць пост у выдаленым канале." + "translation": "Немагчыма апублікаваць у выдаленым канале." }, { "id": "api.post.check_for_out_of_channel_mentions.message.one", - "translation": "@{{.Username}} не атрымаў паведамлення аб гэтай згадцы, паколькі яго няма ў канале." + "translation": "@{{.Username}} не атрымаў апавяшчэння пра гэтае згадванне, бо яго няма ў канале." }, { "id": "api.post.check_for_out_of_channel_mentions.message.multiple", - "translation": "@{{.Usernames}} і @{{.LastUsername}} не атрымалі апавяшчэння аб гэтай згадцы, паколькі іх няма ў канале." + "translation": "@{{.Usernames}} і @{{.LastUsername}} не атрымалі апавяшчэння пра гэтае згадванне, бо іх няма ў канале." }, { "id": "api.post.check_for_out_of_channel_groups_mentions.message.one", - "translation": "@{{.Usernames}} не апавешчаныя гэтай згадкай, таму што яны не з'яўляюцца ўдзельнікамі канала. Яны не могуць быць дададзены ў канал, таму што яны не з'яўляюцца ўдзельнікамі звязаных груп. Каб дадаць іх у канал, яны павінны быць дададзены ў звязаныя групы." + "translation": "@{{.Username}} не атрымаў апавяшчэння пра гэтае згадванне, бо яго няма ў канале. Яго нельга дадаць у канал, бо ён не з'яўляецца ўдзельнікам звязаных груп. Каб дадаць яго ў гэты канал, яго трэба дадаць у звязаныя групы." }, { "id": "api.post.check_for_out_of_channel_groups_mentions.message.multiple", - "translation": "@{{.Usernames}} і @{{.LastUsername}} не апавешчаныя гэтай згадкай, таму што яны не з'яўляюцца ўдзельнікамі канала. Яны не могуць быць дададзены ў канал, таму што яны не з'яўляюцца ўдзельнікамі звязаных груп. Каб дадаць іх у канал, яны павінны быць дададзены ў звязаныя групы." + "translation": "@{{.Usernames}} і @{{.LastUsername}} не атрымалі апавяшчэння пра гэтае згадванне, бо іх няма ў канале. Іх нельга дадаць у канал, бо яны не з'яўляюцца ўдзельнікамі звязаных груп. Каб дадаць іх у гэты канал, іх трэба дадаць у звязаныя групы." }, { "id": "api.post.check_for_out_of_channel_group_users.message.none", @@ -5985,7 +5985,7 @@ }, { "id": "api.plugin.upload.no_file.app_error", - "translation": "Адсутнічае файл у запыце тыпу multipart/form." + "translation": "Адсутнічае файл у запыце multipart/form." }, { "id": "api.plugin.upload.file.app_error", @@ -5993,23 +5993,23 @@ }, { "id": "api.plugin.upload.array.app_error", - "translation": "Файлавы масіў пусты ў запыце multipart/form." + "translation": "Масіў файлаў пусты ў запыце multipart/form." }, { "id": "api.plugin.install.download_failed.app_error", - "translation": "Пры спампоўцы плагіна адбылася памылка." + "translation": "Памылка пры загрузцы плагіна." }, { "id": "api.outgoing_webhook.disabled.app_error", - "translation": "Выходныя вэбхукі адключаныя сістэмным адміністратарам." + "translation": "Выходныя вэбхукі адключаны сістэмным адміністратарам." }, { "id": "api.oauth.singup_with_oauth.invalid_link.app_error", - "translation": "Спасылка для рэгістрацыі, здаецца, няправільная." + "translation": "Спасылка для рэгістрацыі выглядае несапраўднай." }, { "id": "api.oauth.singup_with_oauth.expired_link.app_error", - "translation": "Спасылка для рэгістрацыі састарэлая." + "translation": "Спасылка для рэгістрацыі састарэла." }, { "id": "api.oauth.singup_with_oauth.disabled.app_error", @@ -6017,15 +6017,15 @@ }, { "id": "api.oauth.revoke_access_token.get.app_error", - "translation": "Памылка атрымання токена з БД перад выдаленнем." + "translation": "Памылка пры атрыманні токена доступу з БД перад выдаленнем." }, { "id": "api.oauth.revoke_access_token.del_token.app_error", - "translation": "Памылка выдалення токена з БД." + "translation": "Памылка пры выдаленні токена доступу з БД." }, { "id": "api.oauth.revoke_access_token.del_session.app_error", - "translation": "Памылка выдалення сесіі з БД." + "translation": "Памылка пры выдаленні сесіі з БД." }, { "id": "api.oauth.register_oauth_app.turn_off.app_error", @@ -6033,35 +6033,35 @@ }, { "id": "api.oauth.redirecting_back", - "translation": "Перанакіраванне назад у дадатак." + "translation": "Пераадрасацыя назад у праграму." }, { "id": "api.oauth.get_access_token.refresh_token.app_error", - "translation": "invalid_grant: Памылковы токен абнаўлення." + "translation": "invalid_grant: Несапраўдны токен абнаўлення." }, { "id": "api.oauth.get_access_token.redirect_uri.app_error", - "translation": "invalid_request: Прадастаўлены redirect_url не адпавядае redirect_url кода аўтарызацыі." + "translation": "invalid_request: Пададзены redirect_uri не супадае з redirect_uri кода аўтарызацыі." }, { "id": "api.oauth.get_access_token.internal_user.app_error", - "translation": "server_error: Адбылася ўнутраная памылка сервера падчас вымання карыстальніка з базы дадзеных." + "translation": "server_error: Унутраная памылка сервера пры атрыманні карыстальніка з базы даных." }, { "id": "api.oauth.get_access_token.internal_session.app_error", - "translation": "server_error: Адбылася ўнутраная памылка сервера падчас захавання сесіі ў базу дадзеных." + "translation": "server_error: Унутраная памылка сервера пры захаванні сесіі ў базу даных." }, { "id": "api.oauth.get_access_token.internal_saving.app_error", - "translation": "server_error: Адбылася ўнутраная памылка сервера падчас захавання токена ў базу дадзеных." + "translation": "server_error: Унутраная памылка сервера пры захаванні токена доступу ў базу даных." }, { "id": "api.oauth.get_access_token.internal.app_error", - "translation": "server_error: Адбылася ўнутраная памылка сервера падчас доступу да базы дадзеных." + "translation": "server_error: Унутраная памылка сервера пры доступе да базы даных." }, { "id": "api.oauth.get_access_token.expired_code.app_error", - "translation": "invalid_grant: Несапраўдны ці пратэрмінаваны код аўтарызацыі." + "translation": "invalid_grant: Несапраўдны або пратэрмінаваны код аўтарызацыі." }, { "id": "api.oauth.get_access_token.disabled.app_error", @@ -6069,15 +6069,15 @@ }, { "id": "api.oauth.get_access_token.credentials.app_error", - "translation": "invalid_client: Няправільныя ўліковыя дадзеныя кліента." + "translation": "invalid_client: Несапраўдныя даныя для аўтэнтыфікацыі кліента." }, { "id": "api.oauth.close_browser", - "translation": "Цяпер Вы можаце закрыць гэтую ўкладку браўзэра." + "translation": "Вы можаце закрыць гэту ўкладку браўзера зараз." }, { "id": "api.oauth.click_redirect", - "translation": "Калі Вы не былі перанакіраваны аўтаматычна, націсніце link" + "translation": "Калі вас не перанакіравалі аўтаматычна, націсніце на спасылку" }, { "id": "api.oauth.authorize_oauth.disabled.app_error", @@ -6089,83 +6089,83 @@ }, { "id": "api.oauth.allow_oauth.redirect_callback.app_error", - "translation": "invalid_request: Прадастаўлены redirect_uri не адпавядае зарэгістраванаму callback_url." + "translation": "invalid_request: Пададзены redirect_uri не супадае з зарэгістраваным callback_url." }, { "id": "api.no_license", - "translation": "Для выкарыстання гэтага endpoint патрабуецца ліцэнзія E10 ці E20." + "translation": "Для выкарыстання гэтай кропкі доступу патрабуецца ліцэнзія E10 або E20." }, { "id": "api.migrate_to_saml.error", - "translation": "Немагчыма перанесці SAML." + "translation": "Немагчыма міграваць SAML." }, { "id": "api.license.upgrade_needed.app_error", - "translation": "Для гэтай функцыі патрабуецца абнаўленне да Enterprise Edition." + "translation": "Функцыя патрабуе абнаўлення да Enterprise Edition." }, { "id": "api.license.request_trial_license.app_error", - "translation": "Не ўдалося атрымаць пробную ліцэнзію, паспрабуйце яшчэ раз ці зьвяжыцеся з support@mattermost.com." + "translation": "Немагчыма атрымаць пробную ліцэнзію, калі ласка, паспрабуйце яшчэ раз або звярніцеся ў support@mattermost.com." }, { "id": "api.license.request-trial.can-start-trial.not-allowed", - "translation": "Тэрмін дзеяння гэтага выпрабавальнага ліцэнзійнага ключа для Mattermost Enterprise Edition скончыўся і больш не дзейнічае. Калі вы хочаце падоўжыць пробны перыяд, калі ласка, [звяжыцеся з нашым аддзелам продажаў] (https://mattermost.com/contact-us/)." + "translation": "Немагчыма прымяніць новую пробную ліцэнзію, бо ў вашым працоўным прасторы ўжо прымянялася пробная ліцэнзія. Калі вы хочаце падоўжыць пробны перыяд, калі ласка, [звяжыцеся з нашым аддзелам продажаў](https://mattermost.com/contact-us/)." }, { "id": "api.license.request-trial.can-start-trial.error", - "translation": "Не ўдалося праверыць, ці можна запусціць пробную версію" + "translation": "Немагчыма праверыць, ці можна пачаць пробны перыяд." }, { "id": "api.license.request-trial.bad-request.terms-not-accepted", - "translation": "Вы павінны прыняць Пагадненне аб ацэнцы праграмнага забеспячэння Mattermost і Палітыку прыватнасці, каб запытаць ліцэнзію." + "translation": "Вы павінны прыняць Пагадненне аб ацэнцы праграмнага забеспячэння Mattermost і Палітыку канфідэнцыяльнасці, каб запытаць ліцэнзію." }, { "id": "api.license.request-trial.bad-request", - "translation": "Лік запытаных карыстачоў няправільна." + "translation": "Колькасць запытаных карыстальнікаў некарэктная." }, { "id": "api.license.remove_expired_license.failed.error", - "translation": "Не ўдалося адправіць паведамленне на электронную пошту аб дэактывацыі ліцэнзіі." + "translation": "Немагчыма паспяхова адправіць электроннае паведамленне аб адключэнні ліцэнзіі." }, { "id": "api.license.client.old_format.app_error", - "translation": "Новы фармат карыстацкай ліцэнзіі пакуль не падтрымліваецца. Калі ласка, укажыце format=old у радку запыту." + "translation": "Новы фармат ліцэнзіі кліента яшчэ не падтрымліваецца. Калі ласка, укажыце format=old у радку запыту." }, { "id": "api.license.add_license.unique_users.app_error", - "translation": "Гэтая ліцэнзія падтрымлівае {{.Users}} карыстальнікаў, у той час як у вашай сістэме {{.Count}} унікальных карыстальнікаў. Унікальныя карыстальнікі падлічваюцца відавочна па адрасе электроннай пошты. Вы можаце ўбачыць агульную колькасць карыстальнікаў у пункце Справаздачы сайта -> Прагляд статыстыкі." + "translation": "Гэтая ліцэнзія падтрымлівае {{.Users}} карыстальнікаў, у той час як у вашай сістэме {{.Count}} унікальных карыстальнікаў. Унікальныя карыстальнікі падлічваюцца асобна па адрасе электроннай пошты. Вы можаце ўбачыць агульную колькасць карыстальнікаў у раздзеле 'Статыстыка сайта' -> 'Прагляд статыстыкі'." }, { "id": "api.license.add_license.save_active.app_error", - "translation": "Ідэнтыфікатар актыўнай ліцэнзіі не захаваны." + "translation": "Ідэнтыфікатар актыўнай ліцэнзіі не захаваўся належным чынам." }, { "id": "api.license.add_license.save.app_error", - "translation": "Ліцэнзія не захавана." + "translation": "Ліцэнзія не захавалася належным чынам." }, { "id": "api.license.add_license.open.app_error", - "translation": "Не ўдалося адкрыць файл ліцэнзіі." + "translation": "Немагчыма адкрыць файл ліцэнзіі." }, { "id": "api.license.add_license.no_file.app_error", - "translation": "Няма файла 'license' у запыце." + "translation": "Няма файла ў раздзеле 'license' у запыце." }, { "id": "api.license.add_license.invalid_count.app_error", - "translation": "Не ўдалося палічыць агульную колькасць унікальных карыстальнікаў." + "translation": "Немагчыма падлічыць агульную колькасць унікальных карыстальнікаў." }, { "id": "api.license.add_license.expired.app_error", - "translation": "Ліцэнзія скончылася ці яшчэ не пачалася." + "translation": "Ліцэнзія скончылася або яшчэ не пачалася." }, { "id": "api.license.add_license.array.app_error", - "translation": "Пусты масіў 'license' у запыце." + "translation": "Пусты масіў у раздзеле 'license' у запыце." }, { "id": "api.ldap_groups.license_error", - "translation": "ваша ліцэнзія не падтрымлівае ldap групы" + "translation": "ваша ліцэнзія не падтрымлівае LDAP-групы" }, { "id": "api.ldap_groups.existing_user_name_error", @@ -6173,7 +6173,7 @@ }, { "id": "api.ldap_groups.existing_reserved_name_error", - "translation": "імя гурта ўжо існуе як зарэзерваванае імя" + "translation": "імя групы ўжо існуе як зарэзерваванае імя" }, { "id": "api.ldap_groups.existing_group_name_error", @@ -6185,55 +6185,55 @@ }, { "id": "api.job.unable_to_download_job.incorrect_job_type", - "translation": "Тып задання, якое вы спрабуеце загрузіць, на дадзены момант не падтрымліваецца" + "translation": "Тып задачы, якую вы спрабуеце спампаваць, у дадзены момант не падтрымліваецца" }, { "id": "api.job.unable_to_download_job", - "translation": "Немагчыма спампаваць гэтае заданне" + "translation": "Немагчыма спампаваць гэту задачу" }, { "id": "api.job.unable_to_create_job.incorrect_job_type", - "translation": "Тып задання, якое вы спрабуеце стварыць, некарэктны" + "translation": "Тып задачы, якую вы спрабуеце стварыць, няправільны" }, { "id": "api.job.retrieve.nopermissions", - "translation": "Тыпы заданняў, якія вы спрабуеце атрымаць, не змяшчаюць дазволаў" + "translation": "Тыпы задач, якія вы спрабуеце атрымаць, не ўтрымліваюць дазволаў" }, { "id": "api.invalid_redirect_url", - "translation": "Некарэктны url перанакіраванні" + "translation": "Пададзены несапраўдны URL пераадрасацыі" }, { "id": "api.invalid_custom_url_scheme", - "translation": "Некарэктная карыстацкая схема url" + "translation": "Пададзена несапраўдная карыстальніцкая схема URL" }, { "id": "app.channel.remove_all_deactivated_members.app_error", - "translation": "Мы не можам выдаліць дэактываваных карыстальнікаў з канала." + "translation": "Мы не змаглі выдаліць дэактываваных карыстальнікаў з канала." }, { "id": "app.channel.post_update_channel_purpose_message.updated_to", - "translation": "%s абнавіў загаловак канала на: %s" + "translation": "%s абнавіў прызначэнне канала на: %s" }, { "id": "app.channel.post_update_channel_purpose_message.updated_from", - "translation": "%s абнавіў загаловак канала з: %s на: %s" + "translation": "%s абнавіў прызначэнне канала з: %s на: %s" }, { "id": "app.channel.post_update_channel_purpose_message.retrieve_user.error", - "translation": "Не атрымалася атрымаць карыстальніка пры спробе абнаўлення паведамлення загалоўка канала %v" + "translation": "Немагчыма атрымаць карыстальніка пры абнаўленні паведамлення аб прызначэнні канала %v" }, { "id": "app.channel.post_update_channel_purpose_message.removed", - "translation": "%s выдаліў загаловак канала (быў: %s)" + "translation": "%s выдаліў прызначэнне канала (было: %s)" }, { "id": "app.channel.post_update_channel_purpose_message.post.error", - "translation": "Не атрымалася абнавіць загаловак канала" + "translation": "Немагчыма адправіць паведамленне аб прызначэнні канала" }, { "id": "app.channel.pinned_posts.app_error", - "translation": "Не ўдалося знайсці прымацаваныя паведамленні." + "translation": "Немагчыма знайсці замацаваныя паведамленні." }, { "id": "app.channel.permanent_delete_members_by_user.app_error", @@ -6241,31 +6241,31 @@ }, { "id": "app.channel.permanent_delete.app_error", - "translation": "Не атрымалася выдаліць канал." + "translation": "Немагчыма выдаліць канал." }, { "id": "app.channel.move_channel.members_do_not_match.error", - "translation": "Немагчыма перамясціць канал, калі ўсе яго ўдзельнікі не з'яўляюцца ўдзельнікамі Каманды." + "translation": "Немагчыма перамясціць канал, калі не ўсе яго ўдзельнікі ўжо з'яўляюцца ўдзельнікамі каманды прызначэння." }, { "id": "app.channel.migrate_channel_members.select.app_error", - "translation": "Не ўдалося зрабіць выбарку ўдзельнікаў каналаў." + "translation": "Немагчыма выбраць партыю ўдзельнікаў канала." }, { "id": "app.channel.get_unread.app_error", - "translation": "Не ўдалося атрымаць спіс непрачытаных паведамленняў у канале." + "translation": "Немагчыма атрымаць непрачытаныя паведамленні канала." }, { "id": "app.channel.get_public_channels.get.app_error", - "translation": "Не ўдалося атрымаць публічныя каналы." + "translation": "Немагчыма атрымаць публічныя каналы." }, { "id": "app.channel.get_private_channels.get.app_error", - "translation": "Немагчыма атрымаць усе каналы." + "translation": "Немагчыма атрымаць прыватныя каналы." }, { "id": "app.channel.get_pinnedpost_count.app_error", - "translation": "Не ўдалося атрымаць колькасць прымацаваных паведамленняў." + "translation": "Немагчыма атрымаць колькасць замацаваных паведамленняў канала." }, { "id": "app.channel.get_more_channels.get.app_error", @@ -6273,35 +6273,35 @@ }, { "id": "app.channel.get_members_by_ids.app_error", - "translation": "Не ўдалося атрымаць удзельнікаў канала." + "translation": "Немагчыма атрымаць удзельнікаў канала." }, { "id": "app.channel.get_members.app_error", - "translation": "Не ўдалося атрымаць удзельнікаў канала." + "translation": "Немагчыма атрымаць удзельнікаў канала." }, { "id": "app.channel.get_member_count.app_error", - "translation": "Не ўдалося атрымаць колькасць удзельнікаў канала." + "translation": "Немагчыма атрымаць колькасць удзельнікаў канала." }, { "id": "app.channel.get_member.missing.app_error", - "translation": "Не знойдзена ніводнага ўдзельніка канала па дадзеным ідэнтыфікатару карыстальніка і ідэнтыфікатару канала." + "translation": "Не знойдзена ніводнага ўдзельніка канала для гэтага ідэнтыфікатара карыстальніка і ідэнтыфікатара канала." }, { "id": "app.channel.get_member.app_error", - "translation": "Не ўдалося атрымаць удзельніка канала." + "translation": "Немагчыма атрымаць удзельніка канала." }, { "id": "app.channel.get_for_post.app_error", - "translation": "Не ўдалося атрымаць канал для дадзенага паведамлення." + "translation": "Немагчыма атрымаць канал для дадзенага паведамлення." }, { "id": "app.channel.get_deleted.missing.app_error", - "translation": "Не існуе выдаленых каналаў." + "translation": "Выдаленых каналаў не існуе." }, { "id": "app.channel.get_deleted.existing.app_error", - "translation": "Не ўдалося знайсці існуючы выдалены канал." + "translation": "Немагчыма знайсці існуючы выдалены канал." }, { "id": "app.channel.get_channels_by_ids.get.app_error", @@ -6325,7 +6325,7 @@ }, { "id": "app.channel.get_by_scheme.app_error", - "translation": "Немагчыма атрымаць каналы для прадстаўленай схемы." + "translation": "Немагчыма атрымаць каналы для дадзенай схемы." }, { "id": "app.channel.get_by_name.missing.app_error", @@ -6333,15 +6333,15 @@ }, { "id": "app.channel.get_by_name.existing.app_error", - "translation": "Не ўдалося знайсці існуючы канал." + "translation": "Немагчыма знайсці існуючы канал." }, { "id": "app.channel.get_all_direct.app_error", - "translation": "Немагчыма атрымаць усе каналы асабістых паведамленняў." + "translation": "Немагчыма атрымаць усе прамыя каналы." }, { "id": "app.channel.get_all_channels_count.app_error", - "translation": "Не ўдалося падлічыць усе каналы." + "translation": "Немагчыма падлічыць усе каналы." }, { "id": "app.channel.get_all_channels.app_error", @@ -6353,15 +6353,15 @@ }, { "id": "app.channel.get.find.app_error", - "translation": "Узнікла праблема пры пошуку канала." + "translation": "Мы сутыкнуліся з памылкай пры пошуку канала {{.channel_id}}." }, { "id": "app.channel.get.existing.app_error", - "translation": "Не ўдалося знайсці існуючы канал." + "translation": "Немагчыма знайсці існуючы канал {{.channel_id}}." }, { "id": "app.channel.delete.app_error", - "translation": "Не атрымалася выдаліць канал." + "translation": "Немагчыма выдаліць канал." }, { "id": "app.channel.create_initial_sidebar_categories.internal_error", @@ -6369,59 +6369,59 @@ }, { "id": "app.channel.create_direct_channel.internal_error", - "translation": "Не ўдалося захаваць канал асабістых паведамленняў." + "translation": "Немагчыма захаваць прамы канал." }, { "id": "app.channel.create_channel.no_team_id.app_error", - "translation": "Для стварэння канала неабходна ўказаць ідэнтыфікатар каманды." + "translation": "Трэба ўказаць ідэнтыфікатар каманды для стварэння канала." }, { "id": "app.channel.create_channel.internal_error", - "translation": "Не ўдалося захаваць канал." + "translation": "Немагчыма захаваць канал." }, { "id": "app.channel.count_posts_since.app_error", - "translation": "Немагчыма падлічыць паведамленні з пазначанай даты." + "translation": "Немагчыма падлічыць паведамленні з указанай даты." }, { "id": "app.channel.clear_all_custom_role_assignments.select.app_error", - "translation": "Не ўдалося атрымаць удзельнікаў канала." + "translation": "Не атрымалася атрымаць удзельнікаў канала." }, { "id": "app.channel.autofollow.app_error", - "translation": "Не ўдалося абнавіць удзел у абмеркаванні для ўказанага карыстальніка" + "translation": "Не атрымалася абнавіць удзел у гілцы для згаданага карыстальніка" }, { "id": "app.channel.analytics_type_count.app_error", - "translation": "Не ўдалося атрымаць колькасць тыпаў каналаў." + "translation": "Немагчыма атрымаць колькасць тыпаў каналаў." }, { "id": "app.bot.permenent_delete.bad_id", - "translation": "Не атрымалася выдаліць робата." + "translation": "Немагчыма выдаліць бота." }, { "id": "app.bot.permanent_delete.internal_error", - "translation": "Не атрымалася выдаліць робата назаўжды." + "translation": "Немагчыма выдаліць бота назаўжды." }, { "id": "app.bot.patchbot.internal_error", - "translation": "Не ўдалося абнавіць бота." + "translation": "Немагчыма абнавіць бота." }, { "id": "api.invalid_channel", - "translation": "Канал, указаны ў запыце, не належыць карыстальніку" + "translation": "Канал, пазначаны ў запыце, не належыць карыстальніку" }, { "id": "api.incoming_webhook.disabled.app_error", - "translation": "Уваходныя вэбхукі адключаныя сістэмным адміністратарам." + "translation": "Уваходныя вэбхукі адключаны сістэмным адміністратарам." }, { "id": "api.image.get.app_error", - "translation": "Запытаны URL-адрас выявы не можа быць разабраны." + "translation": "Немагчыма разабраць URL выявы, які быў запытаны." }, { "id": "api.getThreadsForUser.bad_params", - "translation": "Параметры Before і After з'яўляюцца ўзаемавыключальнымі з getThreadsForUser" + "translation": "Параметры Before і After для getThreadsForUser узаемавыключальныя" }, { "id": "api.file.write_file.app_error", @@ -6429,35 +6429,35 @@ }, { "id": "api.file.upload_file.too_large_detailed.app_error", - "translation": "Не атрымалася загрузіць файл {{.Filename}}. {{.Length}} байт перавышае максімальна дазволены ліміт {{.Limit}} байт." + "translation": "Немагчыма загрузіць файл {{.Filename}}. {{.Length}} байт перавышае максімальна дазволеныя {{.Limit}} байт." }, { "id": "api.file.upload_file.storage.app_error", - "translation": "Немагчыма загрузіць файл. Сховішча малюнкаў не наладжана." + "translation": "Немагчыма загрузіць файл. Сховішча выяў не наладжана." }, { "id": "api.file.upload_file.read_request.app_error", - "translation": "Немагчыма загрузіць файл(ы). Памылка чытання ці апрацоўкі дадзеных запыта." + "translation": "Немагчыма загрузіць файл(ы). Памылка пры чытанні або разборы даных запыту." }, { "id": "api.file.upload_file.read_form_value.app_error", - "translation": "Не атрымалася загрузіць файл(ы). Памылка ў значэнні {{.Formname}}." + "translation": "Немагчыма загрузіць файл(ы). Памылка пры чытанні значэння для {{.Formname}}." }, { "id": "api.file.upload_file.multiple_channel_ids.app_error", - "translation": "Не атрымалася загрузіць файл(ы). Некалькі канфліктуючых channel_ids." + "translation": "Немагчыма загрузіць файл(ы). Некалькі супярэчлівых channel_ids." }, { "id": "api.file.upload_file.large_image_detailed.app_error", - "translation": "Памер файла {{.Filename}} ({{.Width}} на {{.Height}} пікселяў) перавышае ліміты." + "translation": "Памеры {{.Filename}} ({{.Width}} на {{.Height}} пікселяў) перавышаюць ліміты." }, { "id": "api.file.upload_file.large_image.app_error", - "translation": "Памер файла перавышае максімальныя памеры і не можа быць загружаны: {{.Filename}}" + "translation": "Файл з памерам, што перавышае максімальныя памеры, не можа быць загружаны: {{.Filename}}" }, { "id": "api.file.upload_file.incorrect_number_of_client_ids.app_error", - "translation": "Не атрымалася загрузіць файл(ы). Ёсць {{.NumClientIds}} client_id для {{.NumFiles}} файлаў." + "translation": "Немагчыма загрузіць файл(ы). Ёсць {{.NumClientIds}} client_ids для {{.NumFiles}} файла(ў)." }, { "id": "api.file.upload_file.incorrect_channelId.app_error", @@ -6465,15 +6465,15 @@ }, { "id": "api.file.test_connection_s3_bucket_does_not_exist.app_error", - "translation": "Пераканайцеся ў даступнасці кошыка Amazon S3 і праверце дазволы на доступ." + "translation": "Пераканайцеся, што ваш бакет Amazon S3 даступны, і праверце дазволы на доступ да яго." }, { "id": "api.file.test_connection_s3_auth.app_error", - "translation": "Немагчыма падлучыцца да S3. Праверце параметры аўтарызацыі і наладкі аўтэнтыфікацыі падлучэння Amazon S3." + "translation": "Немагчыма падключыцца да S3. Праверце параметры аўтарызацыі і налады аўтэнтыфікацыі для падключэння да Amazon S3." }, { "id": "api.file.test_connection.app_error", - "translation": "Няма доступу да файлавага сховішча." + "translation": "Немагчыма атрымаць доступ да сховішча файлаў." }, { "id": "api.file.remove_file.app_error", @@ -6485,7 +6485,7 @@ }, { "id": "api.file.read_file.reading_local.app_error", - "translation": "Адбылася памылка пры чытанні з лакальнага сервернага сховішча." + "translation": "Памылка пры чытанні з лакальнага сервернага сховішча файлаў." }, { "id": "api.file.read_file.app_error", @@ -6493,35 +6493,35 @@ }, { "id": "api.file.no_driver.app_error", - "translation": "Не абраны файл драйвера." + "translation": "Не выбраны драйвер файла." }, { "id": "api.file.move_file.app_error", - "translation": "Немагчыма перанесці файл." + "translation": "Немагчыма перамясціць файл." }, { "id": "api.file.list_directory.app_error", - "translation": "Немагчыма адлюстраваць каталог." + "translation": "Немагчыма пералічыць змест каталога." }, { "id": "api.file.get_public_link.no_post.app_error", - "translation": "Не ўдалося атрымаць публічную спасылку для файла. Файл павінен быць прымацаваны да паведамлення, якое можа быць прачытана бягучым карыстальнікам." + "translation": "Немагчыма атрымаць публічную спасылку на файл. Файл павінен быць далучаны да паведамлення, якое можа быць прачытана бягучым карыстальнікам." }, { "id": "api.file.get_public_link.disabled.app_error", - "translation": "Публічныя спасылкі адключаныя." + "translation": "Публічныя спасылкі адключаны." }, { "id": "api.file.get_file_thumbnail.no_thumbnail.app_error", - "translation": "Файл не мае мініяцюры." + "translation": "Файл не мае мініяцюрнай выявы." }, { "id": "api.file.get_file_preview.no_preview.app_error", - "translation": "Файл не змяшчае выявы папярэдняга прагляду." + "translation": "Файл не мае выявы папярэдняга прагляду." }, { "id": "api.file.get_file.public_invalid.app_error", - "translation": "Няправільная публічная спасылка." + "translation": "Публічная спасылка выглядае несапраўднай." }, { "id": "api.file.file_size.app_error", @@ -6529,23 +6529,23 @@ }, { "id": "api.file.file_reader.app_error", - "translation": "Немагчыма атрымаць аб'ект чытання файлаў." + "translation": "Немагчыма атрымаць праграму для чытання файлаў." }, { "id": "api.file.file_mod_time.app_error", - "translation": "Немагчыма атрымаць час апошняй змены файла." + "translation": "Немагчыма атрымаць час апошняй мадыфікацыі файла." }, { "id": "api.file.file_exists.app_error", - "translation": "Немагчыма праверыць існаванне файла." + "translation": "Немагчыма праверыць, ці існуе файл." }, { "id": "api.file.attachments.disabled.app_error", - "translation": "Укладанні адключаныя на гэтым серверы." + "translation": "Далучэнне файлаў адключана на гэтым серверы." }, { "id": "api.file.append_file.app_error", - "translation": "Немагчыма дадаць дадзеныя да файла." + "translation": "Немагчыма дадаць даныя да файла." }, { "id": "api.export.export_not_found.app_error", @@ -6553,71 +6553,71 @@ }, { "id": "api.error_set_first_admin_visit_marketplace_status", - "translation": "Памылка пры спробе захаваць статус першага наведвання крамы адміністратарам у краме." + "translation": "Памылка пры спробе захаваць статус першага наведвання маркетплейса адміністратарам у сховішчы." }, { "id": "api.error_get_first_admin_visit_marketplace_status", - "translation": "Памылка пры спробе атрымаць з крамы статус першага наведвання крамы адміністратарам." + "translation": "Памылка пры спробе атрымаць са сховішча статус першага наведвання маркетплейса адміністратарам." }, { "id": "api.emoji.upload.open.app_error", - "translation": "Немагчыма стварыць эмоджы. Адбылася памылка пры спробе адкрыць прымацаваную выяву." + "translation": "Немагчыма стварыць эмодзі. Адбылася памылка пры спробе адкрыць далучаную выяву." }, { "id": "api.emoji.upload.large_image.too_large.app_error", - "translation": "Немагчыма стварыць эмоджы. Выява павінна быць менш {{.MaxWidth}} х {{.MaxHeight}}." + "translation": "Немагчыма стварыць эмодзі. Выява павінна быць меншай за {{.MaxWidth}} на {{.MaxHeight}}." }, { "id": "api.emoji.upload.large_image.gif_encode_error", - "translation": "Немагчыма стварыць эмоджы. Адбылася памылка пры кадаванні GIF выявы." + "translation": "Немагчыма стварыць эмодзі. Адбылася памылка пры спробе кадаваня GIF-выявы." }, { "id": "api.emoji.upload.large_image.gif_decode_error", - "translation": "Немагчыма стварыць эмоджы. Адбылася памылка пры дэкадаванні GIF выявы." + "translation": "Немагчыма стварыць эмодзі. Адбылася памылка пры спробе дэкадавання GIF-выявы." }, { "id": "api.emoji.upload.large_image.encode_error", - "translation": "Немагчыма стварыць эмоджы. Адбылася памылка пры кадаванні выявы." + "translation": "Немагчыма стварыць эмодзі. Адбылася памылка пры спробе кадаваня выявы." }, { "id": "api.emoji.upload.large_image.decode_error", - "translation": "Немагчыма стварыць эмоджы. Адбылася памылка пры дэкадаванні выявы." + "translation": "Немагчыма стварыць эмодзі. Адбылася памылка пры спробе дэкадавання выявы." }, { "id": "api.emoji.upload.image.app_error", - "translation": "Немагчыма стварыць эмоджы. Павінен быць PNG, JPEG ці GIF файл." + "translation": "Немагчыма стварыць эмодзі. Файл павінен быць у фармаце PNG, JPEG або GIF." }, { "id": "api.emoji.storage.app_error", - "translation": "Сховішча файлаў не наладжана. Калі ласка, наладзьце S3 або лакальнае сервернае файлавае сховішча." + "translation": "Сховішча файлаў наладжана няправільна. Калі ласка, наладзьце S3 або лакальнае сервернае сховішча файлаў." }, { "id": "api.emoji.get_image.read.app_error", - "translation": "Не атрымалася прачытаць файл выявы для эмоджы." + "translation": "Немагчыма прачытаць файл выявы для эмодзі." }, { "id": "api.emoji.get_image.decode.app_error", - "translation": "Не атрымалася дэкадаваць файл выявы для эмоджы." + "translation": "Немагчыма дэкадаваць файл выявы для эмодзі." }, { "id": "api.emoji.disabled.app_error", - "translation": "Карыстальніцкія смайлы былі адключаныя сістэмным адміністратарам." + "translation": "Карыстальніцкія эмодзі адключаны сістэмным адміністратарам." }, { "id": "api.emoji.create.too_large.app_error", - "translation": "Немагчыма стварыць эмоджы. Выява павінна быць не больш за 1 MB." + "translation": "Немагчыма стварыць эмодзі. Памер выявы павінен быць менш за 512 КБ." }, { "id": "api.emoji.create.parse.app_error", - "translation": "Немагчыма стварыць эмоджы. Незразумелы запыт." + "translation": "Немагчыма стварыць эмодзі. Немагчыма зразумець запыт." }, { "id": "api.emoji.create.internal_error", - "translation": "server_error: Адбылася ўнутраная памылка сервера пры стварэнні эмоджы." + "translation": "server_error: Узнікла ўнутраная памылка сервера пры стварэнні эмодзі." }, { "id": "api.emoji.create.duplicate.app_error", - "translation": "Не ўдалося стварыць эмоджы. Ужо існуе іншы эмоджы з такім жа імем." + "translation": "Немагчыма стварыць эмодзі. Іншае эмодзі з такой жа назвай ужо існуе." }, { "id": "api.email_batching.send_batched_email_notification.title", @@ -6626,14 +6626,14 @@ { "id": "api.email_batching.send_batched_email_notification.subject", "translation": { - "few": "", - "many": "", + "few": "[{{.SiteName}}] Новыя апавяшчэнне за {{.Day}} {{.Month}}, {{.Year}}", + "many": "[{{.SiteName}}] Новыя апавяшчэнне за {{.Day}} {{.Month}}, {{.Year}}", "one": "[{{.SiteName}}] Новае апавяшчэнне за {{.Day}} {{.Month}}, {{.Year}}" } }, { "id": "api.command_join.fail.app_error", - "translation": "Пры падключэнні да канала адбылася памылка." + "translation": "Адбылася памылка пры далучэнні да канала." }, { "id": "api.command_join.desc", @@ -6641,15 +6641,19 @@ }, { "id": "api.command_invite_people.permission.app_error", - "translation": "У вас няма правоў для запрашэння новых удзельнікаў." + "translation": "У вас няма правоў запрашаць новых карыстальнікаў на гэты сервер." }, { "id": "api.command_invite.user_not_in_team.app_error", - "translation": "@{{.Username}} не з'яўляецца ўдзельнікам каманды." + "translation": "Вы можаце дадаць {{.Users}} у гэты канал, калі яны стануць членамі каманды **{{.Team}}**." }, { "id": "api.command_invite.user_already_in_channel.app_error", - "translation": "{{.User}} ужо знаходзіцца ў канале." + "translation": { + "few": "{{.User}} ужо знаходзяцца ў канале.", + "many": "{{.User}} ужо знаходзяцца ў канале.", + "one": "{{.User}} ужо знаходзіцца ў канале." + } }, { "id": "api.command_invite.success", @@ -6657,27 +6661,27 @@ }, { "id": "api.command_invite.private_channel.app_error", - "translation": "Немагчыма знайсці канал {{.Channel}}. Калі ласка, выкарыстоўвайце спіс каналаў для ідэнтыфікацыі." + "translation": "Не атрымалася знайсці канал {{.Channel}}. Калі ласка, выкарыстоўвайце [ідэнтыфікатар канала](https://docs.mattermost.com/messaging/managing-channels.html#naming-a-channel) для ідэнтыфікацыі каналаў." }, { "id": "api.command_invite.permission.app_error", - "translation": "У вас недастаткова правоў для дадання {{.User}} у {{.Channel}}." + "translation": "У вас недастаткова правоў, каб дадаць {{.User}} у {{.Channel}}." }, { "id": "api.command_invite.missing_user.app_error", - "translation": "Мы не змаглі знайсці карыстальніка. Верагодна, ён заблакаваны Сістэмным адміністратарам." + "translation": "Мы не змаглі знайсці карыстальніка {{.User}}. Магчыма, ён быў дэактываваны адміністратарам сістэмы." }, { "id": "api.command_invite.missing_message.app_error", - "translation": "Адсутнічае імя карыстальніка і канал." + "translation": "Адсутнічае імя карыстальніка і/або канал." }, { "id": "api.command_invite.fail.app_error", - "translation": "Пры падключэнні да канала адбылася памылка." + "translation": "Адбылася памылка пры далучэнні да канала." }, { "id": "api.command_invite.directchannel.app_error", - "translation": "Вы не можаце дадаць кагосьці ў канал асабістых паведамленняў." + "translation": "Вы не можаце дадаць каго-небудзь у канал прамых паведамленняў." }, { "id": "api.command_invite.desc", @@ -6685,7 +6689,7 @@ }, { "id": "api.command_invite.channel.error", - "translation": "Не ўдалося знайсці канал {{.Channel}}. Для ідэнтыфікацыі каналаў выкарыстоўвайце [дэскрыптар канала](https://docs.mattermost.com/channels/channel-naming-conventions.html)." + "translation": "Не атрымалася знайсці канал {{.Channel}}. Калі ласка, выкарыстоўвайце [ідэнтыфікатар канала](https://docs.mattermost.com/messaging/managing-channels.html#naming-a-channel) для ідэнтыфікацыі каналаў." }, { "id": "api.command_invite.channel.app_error", @@ -6693,55 +6697,55 @@ }, { "id": "api.command_help.desc", - "translation": "Адкрыць старонку дапамогі Mattermost" + "translation": "Паказаць даведачнае паведамленне Mattermost" }, { "id": "api.command_groupmsg.permission.app_error", - "translation": "У вас няма адпаведных дазволаў для стварэння новага групавога паведамлення." + "translation": "У вас няма адпаведных правоў для стварэння новага групавога паведамлення." }, { "id": "api.command_groupmsg.min_users.app_error", - "translation": "Мінімальная колькасць атрымальнікаў групавых паведамленняў абмежавана {{.MinUsers}} карыстальнікамі." + "translation": "Групавыя паведамленні абмежаваны мінімум {{.MinUsers}} карыстальнікамі." }, { "id": "api.command_groupmsg.max_users.app_error", - "translation": "Максімальная колькасць атрымальнікаў групавых паведамленняў абмежавана {{.MaxUsers}} карыстальнікамі." + "translation": "Групавыя паведамленні абмежаваны максімум {{.MaxUsers}} карыстальнікамі." }, { "id": "api.command_groupmsg.invalid_user.app_error", "translation": { - "few": "", - "many": "", + "few": "Не ўдалося знайсці карыстальнікаў", + "many": "Не ўдалося знайсці карыстальнікаў", "one": "Не ўдалося знайсці карыстальніка: {{.Users}}" } }, { "id": "api.command_groupmsg.group_fail.app_error", - "translation": "Пры стварэнні групавога паведамлення адбылася памылка." + "translation": "Адбылася памылка пры стварэнні групавога паведамлення." }, { "id": "api.command_groupmsg.fail.app_error", - "translation": "Адбылася памылка пры перадачы паведамлення карыстальнікам." + "translation": "Адбылася памылка пры адпраўцы паведамленняў карыстальнікам." }, { "id": "api.command_groupmsg.desc", - "translation": "Адправіць групавое паведамленне ўказаным карыстальнікам" + "translation": "Адпраўляе групавое паведамленне выбраным карыстальнікам" }, { "id": "api.command_expand_collapse.fail.app_error", - "translation": "Адбылася памылка пры адкрыцці перадпряглядаў." + "translation": "Адбылася памылка пры разгортванні папярэдніх праглядаў." }, { "id": "api.command_expand.success", - "translation": "Цяпер спасылкі на выявы паказваюцца цалкам" + "translation": "Спасылкі на выявы цяпер разгортваюцца па змаўчанні" }, { "id": "api.command_expand.desc", - "translation": "Адключыць аўтаматычнае згортванне малюнкаў перадпрагляду" + "translation": "Адключыць аўтаматычнае згортванне папярэдніх праглядаў выяў" }, { "id": "api.command_echo.message.app_error", - "translation": "Перад паведамленьнем павінна быць каманда /echo." + "translation": "Паведамленне павінна быць прадастаўлена з камандай /echo." }, { "id": "api.command_echo.hint", @@ -6749,11 +6753,11 @@ }, { "id": "api.command_echo.high_volume.app_error", - "translation": "Вялікі аб'ём рэха-запыту, немагчыма апрацаваць запыт." + "translation": "Высокі аб'ём эха-запыту, не магчыма апрацаваць запыт." }, { "id": "api.command_echo.desc", - "translation": "Вывесці тэкст ад імя вашага ўліковага запісу" + "translation": "Адлюстраваць тэкст ад вашага уліковага запісу" }, { "id": "api.command_echo.delay.app_error", @@ -6761,51 +6765,51 @@ }, { "id": "api.command_dnd.success", - "translation": "Рэжым \"Не турбаваць\" уключаны. Вы не будзеце атрымліваць абвесткі на працоўным стале або мабільных прыладах да таго часу, пакуль не адключыце гэты рэжым." + "translation": "Рэжым \"Не турбаваць\" уключаны. Вы не будзеце атрымліваць настольныя або мабільныя push-паведамленні, пакуль рэжым \"Не турбаваць\" не будзе выключаны." }, { "id": "api.command_dnd.desc", - "translation": "Рэжым \"Не турбаваць\" адключае настольныя і мабільныя push-паведамлення." + "translation": "Рэжым \"Не турбаваць\" адключае настольныя і мабільныя push-паведамленні." }, { "id": "api.command_custom_status.success", - "translation": "Ваш статус устаноўлены ў \"{{.EmojiName}} {{.StatusMessage}}\". Вы можаце змяніць свой статус з усплывальнага акна ў загалоўку бакавой панэлі канала." + "translation": "Ваш статус усталяваны на \"{{.EmojiName}} {{.StatusMessage}}\". Вы можаце змяніць свой статус з усплываючага акна статусу ў загалоўку бакавой панэлі канала." }, { "id": "api.command_custom_status.hint", - "translation": "[:emoji_name:] [status_message] або скінуць" + "translation": "[:emoji_name:] [status_message] або ачысціць" }, { "id": "api.command_custom_status.desc", - "translation": "Устанавіць або скінуць ваш статус" + "translation": "Усталяваць або ачысціць ваш статус" }, { "id": "api.command_custom_status.clear.success", - "translation": "Ваш статус быў скінуты." + "translation": "Ваш статус быў ачышчаны." }, { "id": "api.command_custom_status.clear.app_error", - "translation": "Памылка ачысткі статусу." + "translation": "Памылка ачышчэння статусу." }, { "id": "api.command_custom_status.app_error", - "translation": "Памылка наладкі статусу." + "translation": "Памылка ўсталявання статусу." }, { "id": "api.command_collapse.success", - "translation": "Цяпер спасылкі на выявы сгортваюцца" + "translation": "Спасылкі на выявы цяпер згортваюцца па змаўчанні" }, { "id": "api.command_collapse.desc", - "translation": "Уключыць згортванне перадпрагляда малюнкаў" + "translation": "Уключыць аўтаматычнае згортванне папярэдніх праглядаў выяў" }, { "id": "api.command_code.message.app_error", - "translation": "Перад паведамленьнем павінна быць каманда /code." + "translation": "Паведамленне павінна быць прадастаўлена з камандай /code." }, { "id": "api.command_code.desc", - "translation": "Паказвае тэкст як блок кода" + "translation": "Паказваць тэкст як блок кода" }, { "id": "api.command_channel_rename.update_channel.app_error", @@ -6813,23 +6817,23 @@ }, { "id": "api.command_channel_rename.too_short.app_error", - "translation": "Назва канала павінна быць не карацейшая за {{.Length}} сімвалаў." + "translation": "Назва канала павінна быць {{.Length}} або больш сімвалаў." }, { "id": "api.command_channel_rename.too_long.app_error", - "translation": "Назва канала павінна быць не даўжэй {{.Length}} сімвалаў." + "translation": "Назва канала павінна быць {{.Length}} або менш сімвалаў." }, { "id": "api.command_channel_rename.permission.app_error", - "translation": "У вас няма правоў на перайменаванне канала." + "translation": "У вас няма адпаведных правоў для перайменавання канала." }, { "id": "api.command_channel_rename.message.app_error", - "translation": "Перад паведамленьнем павінна быць каманда /rename." + "translation": "Паведамленне павінна быць прадастаўлена з камандай /rename." }, { "id": "api.command_channel_rename.direct_group.app_error", - "translation": "Немагчыма перайменаваць канал асабістых паведамленняў." + "translation": "Немагчыма перайменаваць каналы прамых паведамленняў." }, { "id": "api.command_channel_rename.channel.app_error", @@ -6849,15 +6853,15 @@ }, { "id": "api.command_channel_purpose.permission.app_error", - "translation": "У вас няма правоў для рэдагавання прызначэння канала." + "translation": "У вас няма адпаведных правоў для рэдагавання прызначэння канала." }, { "id": "api.command_channel_purpose.message.app_error", - "translation": "Перад паведамленьнем павінна быць каманда /purpose." + "translation": "Паведамленне павінна быць прадастаўлена з камандай /purpose." }, { "id": "api.command_channel_purpose.direct_group.app_error", - "translation": "Немагчыма ўсталяваць прызначэнне для каналаў асабістых паведамленняў. Выкарыстоўвайце /header, каб замест гэтага ўсталяваць загаловак." + "translation": "Немагчыма ўсталяваць прызначэнне для каналаў прамых паведамленняў. Выкарыстоўвайце /header, каб усталяваць загаловак замест гэтага." }, { "id": "api.command_channel_purpose.desc", @@ -6877,11 +6881,11 @@ }, { "id": "api.command_channel_header.permission.app_error", - "translation": "У вас няма правоў для рэдагавання загалоўка канала." + "translation": "У вас няма адпаведных правоў для рэдагавання загалоўка канала." }, { "id": "api.command_channel_header.message.app_error", - "translation": "Перад паведамленьнем павінна быць каманда /header." + "translation": "Тэкст павінен быць прадастаўлены з камандай /header." }, { "id": "api.command_channel_header.desc", @@ -6893,51 +6897,51 @@ }, { "id": "api.command_away.success", - "translation": "Цяпер ваш стан \"Адышоў\"" + "translation": "Цяпер вы не на месцы" }, { "id": "api.command_away.desc", - "translation": "Устанавіць стан \"Адышоў\"" + "translation": "Усталяваць статус \"Не на месцы\"" }, { "id": "api.command.team_mismatch.app_error", - "translation": "Немагчыма абнавіць каманды(commands) сярод каманд(teams)." + "translation": "Немагчыма абнавіць каманды паміж камандамі." }, { "id": "api.command.invite_people.no_email", - "translation": "Калі ласка, укажыце адзін або некалькі дзейных адрасоў электроннай пошты" + "translation": "Калі ласка, укажыце адзін або некалькі сапраўдных адрасоў электроннай пошты" }, { "id": "api.command.invite_people.invite_off", - "translation": "На гэтым серверы адключана стварэнне карыстальнікаў, запрашэнне(я) не адпраўлена" + "translation": "Стварэнне карыстальнікаў на гэтым серверы адключана, запрашэнне(і) не адпраўлена(ы)" }, { "id": "api.command.invite_people.fail", - "translation": "Узнікла памылка пры адпраўцы запрашэння па электроннай пошце" + "translation": "Адбылася памылка пры адпраўцы запрашэння(аў) па электроннай пошце" }, { "id": "api.command.invite_people.email_off", - "translation": "Электронная пошта не настроена, запрашэнні не адпраўлены" + "translation": "Электронная пошта не наладжана, запрашэнне(і) не адпраўлена(ы)" }, { "id": "api.command.invite_people.email_invitations_off", - "translation": "Запрашэнні па электроннай пошце адключаныя, запрашэнне не адпраўлена" + "translation": "Запрашэнні па электроннай пошце адключаны, запрашэнне(і) не адпраўлена(ы)" }, { "id": "api.command.invite_people.desc", - "translation": "Адправіць ліст з запрашэннем у вашу каманду Mattermost" + "translation": "Адправіць запрашэнне па электроннай пошце ў вашу каманду Mattermost" }, { "id": "api.command.execute_command.start.app_error", - "translation": "Не знойдзены трыгер каманды." + "translation": "Трыгер каманды не знойдзены." }, { "id": "api.command.execute_command.not_found.app_error", - "translation": "Каманда з трыгерам '{{.Trigger}}' не знойдзена. Каб адправіць паведамленне, якое пачынаецца з \"/\", паспрабуйце дадаць прабел ў пачатку паведамлення." + "translation": "Каманда з трыгерам '{{.Trigger}}' не знойдзена. Каб адправіць паведамленне, якое пачынаецца з \"/\", паспрабуйце дадаць пусты прабел у пачатку паведамлення." }, { "id": "api.command.execute_command.format.app_error", - "translation": "У слове-трыгеры каманды адсутнічае пачатковы сімвал слэш" + "translation": "У слове каманды-трыгера адсутнічае пачатковы сімвал касай рысы" }, { "id": "api.command.execute_command.failed_resp.app_error", @@ -6949,27 +6953,27 @@ }, { "id": "api.command.execute_command.failed.app_error", - "translation": "Каманда з трыгерам '{{.Trigger}}' завяршылася з памылкай." + "translation": "Каманда з трыгерам '{{.Trigger}}' не ўдалася." }, { "id": "api.command.execute_command.create_post_failed.app_error", - "translation": "Каманда '{{.Trigger}}' не адправіла адказ. Калі ласка, звярніцеся да вашага сістэмнага адміністратара." + "translation": "Камандзе '{{.Trigger}}' не ўдалося адправіць адказ. Калі ласка, звярніцеся да вашага адміністратара сістэмы." }, { "id": "api.command.duplicate_trigger.app_error", - "translation": "Гэтае трыгер слова ўжо выкарыстоўваецца. Калі ласка, абярыце іншае." + "translation": "Гэта слова-трыгер ужо выкарыстоўваецца. Калі ласка, выберыце іншае слова." }, { "id": "api.command.disabled.app_error", - "translation": "Каманды былі адключаныя сістэмным адміністратарам." + "translation": "Каманды адключаны адміністратарам сістэмы." }, { "id": "api.command.command_post.forbidden.app_error", - "translation": "Указаны карыстальнік не з'яўляецца ўдзельнікам названага канала." + "translation": "Указаны карыстальнік не з'яўляецца ўдзельнікам указанага канала." }, { "id": "api.command.admin_only.app_error", - "translation": "Інтэграцыі могуць быць выкананы толькі адміністратарамі." + "translation": "Інтэграцыі абмежаваны толькі для адміністратараў." }, { "id": "api.cloud.request_error", @@ -6977,83 +6981,83 @@ }, { "id": "api.cloud.license_error", - "translation": "Ваша ліцэнзія не падтрымлівае воблачныя запыты." + "translation": "Ваша ліцэнзія не падтрымлівае хмарныя запыты." }, { "id": "api.cloud.cws_webhook_event_missing_error", - "translation": "Падзея вэбхука не была апрацавана. Або яна адсутнічае, або некарэктная." + "translation": "Падзея Webhook не была апрацавана. Яна адсутнічае або не з'яўляецца сапраўднай." }, { "id": "api.cloud.app_error", - "translation": "Унутраная памылка падчас запыту воблачнага API." + "translation": "Унутраная памылка падчас запыту да хмарнага API." }, { "id": "api.channel.update_team_member_roles.scheme_role.app_error", - "translation": "Абраная роля кіруецца схемай і не можа быць ужытая наўпрост да ўдзельніка каманды." + "translation": "Выбраная роля кіруецца Схемой і таму не можа быць ужыта непасрэдна да члена каманды." }, { "id": "api.channel.update_team_member_roles.changing_guest_role.app_error", - "translation": "Недапушчальнае абнаўленне ўдзельніка каманды: вы не можаце дадаваць ці выдаляць гасцявую ролю ўручную." + "translation": "Недапушчальнае абнаўленне члена каманды: вы не можаце дадаваць або выдаляць ролю госця ўручную." }, { "id": "api.channel.update_channel_scheme.scheme_scope.error", - "translation": "Немагчыма прымяніць схему да канала, бо дадзеная схема не з'яўляецца схемай канала." + "translation": "Нельга ўсталяваць схему для канала, бо прадастаўленая схема не з'яўляецца схемай канала." }, { "id": "api.channel.update_channel_scheme.license.error", - "translation": "Ваша ліцэнзія не падтрымлівае змену схемы канала" + "translation": "Ваша ліцэнзія не падтрымлівае абнаўленне схемы канала" }, { "id": "api.channel.update_channel_privacy.default_channel_error", - "translation": "Канал па змаўчанні нельга зрабіць прыватным." + "translation": "Канал па змаўчанні не можа быць прыватным." }, { "id": "api.channel.update_channel_member_roles.scheme_role.app_error", - "translation": "Абраная роля кіруецца схемай і не можа быць ужытая наўпрост да ўдзельніка канала." + "translation": "Прызначаная роля кіруецца Схемой і таму не можа быць ужыта непасрэдна да члена канала." }, { "id": "api.channel.update_channel_member_roles.guest_and_user.app_error", - "translation": "Недапушчальнае абнаўленне ўдзельніка канала: карыстальнік павінен быць госцем ці карыстальнікам, але не абодвума." + "translation": "Няправільнае абнаўленне члена канала: карыстальнік павінен быць госцем або карыстальнікам, але не абодвума." }, { "id": "api.channel.update_channel_member_roles.changing_guest_role.app_error", - "translation": "Недапушчальнае абнаўленне ўдзельніка канала: Вы не можаце дадаваць ці выдаляць гасцявую ролю ўручную." + "translation": "Няправільнае абнаўленне члена канала: вы не можаце дадаваць або выдаляць ролю госця ўручную." }, { "id": "api.channel.update_channel.typechange.app_error", - "translation": "Тып канала не можа быць абноўлены." + "translation": "Тып канала нельга абнавіць." }, { "id": "api.channel.update_channel.tried.app_error", - "translation": "Спроба выканання недапушчальнага абнаўлення канала па змаўчанні {{.Channel}}." + "translation": "Спроба выканаць некарэктнае абнаўленне канала па змаўчанні {{.Channel}}." }, { "id": "api.channel.update_channel.deleted.app_error", - "translation": "Дадзены канал быў перамешчаны ў архіў, альбо выдалены." + "translation": "Канал быў заархіваваны або выдалены." }, { "id": "api.channel.restore_channel.unarchived", - "translation": "{{.Username}} разархіваваў канал." + "translation": "{{.Username}} адкрыў канал." }, { "id": "api.channel.restore_channel.restored.app_error", - "translation": "Немагчыма разархіваваць канал. Ён не архіваваны." + "translation": "Нельга адкрыць канал. Канал не заархіваваны." }, { "id": "api.channel.rename_channel.cant_rename_group_messages.app_error", - "translation": "Вы не можаце перайменаваць канал групавых паведамленняў." + "translation": "Нельга перайменаваць канал групавых паведамленняў." }, { "id": "api.channel.rename_channel.cant_rename_direct_messages.app_error", - "translation": "Вы не можаце перайменаваць канал асабістых паведамленняў." + "translation": "Вы не можаце перайменаваць канал прамых паведамленняў." }, { "id": "api.channel.remove_user_from_channel.app_error", - "translation": "Немагчыма выдаліць карыстальніка з гэтага тыпу канала." + "translation": "Нельга выдаліць карыстальніка з гэтага тыпу канала." }, { "id": "api.channel.remove_members.denied", - "translation": "Выдаленне з канала забаронена наступным карыстальнікам з-за абмежаванняў групы: {{.UserIDs}}" + "translation": "Выдаленне з канала забаронена наступным карыстальнікам з-за абмежаванняў групы: {{ .UserIDs }}" }, { "id": "api.channel.remove_member.removed", @@ -7061,135 +7065,135 @@ }, { "id": "api.channel.remove_member.group_constrained.app_error", - "translation": "Немагчыма выдаліць карыстальніка з канала ў сувязі з абмежаваннем групы." + "translation": "Нельга выдаліць карыстальніка з канала з абмежаваннямі групы." }, { "id": "api.channel.remove_channel_member.type.app_error", - "translation": "Не атрымалася выдаліць карыстальніка з канала." + "translation": "Нельга выдаліць карыстальніка з канала." }, { "id": "api.channel.remove.default.app_error", - "translation": "Немагчыма выдаліць карыстальніка з канала па змаўчанні {{.Channel}}." + "translation": "Нельга выдаліць карыстальніка з канала па змаўчанні {{.Channel}}." }, { "id": "api.channel.post_user_add_remove_message_and_forget.error", - "translation": "Не атрымалася адправіць паведамленне аб далучэнні/пакіданні" + "translation": "Не атрымалася апублікаваць паведамленне аб далучэнні/выхадзе" }, { "id": "api.channel.post_update_channel_header_message_and_forget.updated_to", - "translation": "%s абнавіў загаловак канала на: %s" + "translation": "%s абнавіў(ла) загаловак канала на: %s" }, { "id": "api.channel.post_update_channel_header_message_and_forget.updated_from", - "translation": "%s абнавіў загаловак канала з: %s на: %s" + "translation": "%s абнавіў(ла) загаловак канала з: %s на: %s" }, { "id": "api.channel.post_update_channel_header_message_and_forget.retrieve_user.error", - "translation": "Не атрымалася атрымаць карыстальніка пры спробе абнаўлення загалоўка канала" + "translation": "Не атрымалася атрымаць карыстальніка пры абнаўленні загалоўка канала" }, { "id": "api.channel.post_update_channel_header_message_and_forget.removed", - "translation": "%s выдаліў загаловак канала (быў: %s)" + "translation": "%s выдаліў загаловак канала (было: %s)" }, { "id": "api.channel.post_update_channel_header_message_and_forget.post.error", - "translation": "Не атрымалася апублікаваць абнаўлення загалоўка канала" + "translation": "Не атрымалася апублікаваць паведамленне аб абнаўленні загалоўка канала" }, { "id": "api.channel.post_update_channel_displayname_message_and_forget.updated_from", - "translation": "%s абнавіў імя канала з: %s на: %s" + "translation": "%s абнавіў адлюстраванае імя канала з: %s на: %s" }, { "id": "api.channel.post_update_channel_displayname_message_and_forget.retrieve_user.error", - "translation": "Не атрымалася атрымаць карыстальніка пры спробе абнаўлення імя канала" + "translation": "Не атрымалася атрымаць карыстальніка пры абнаўленні поля DisplayName канала" }, { "id": "api.channel.post_update_channel_displayname_message_and_forget.create_post.error", - "translation": "Ну атрымалася адправіць паведамленне аб абнаўленні бачнага імя канала" + "translation": "Не атрымалася апублікаваць паведамленне аб абнаўленні адлюстраванага імя" }, { "id": "api.channel.post_channel_privacy_message.error", - "translation": "Немагчыма адправіць паведамленне абнаўлення прыватнасці канала." + "translation": "Не атрымалася апублікаваць паведамленне аб абнаўленні прыватнасці канала." }, { "id": "api.channel.patch_update_channel.forbidden.app_error", - "translation": "Не ўдалося абнавіць канал." + "translation": "Не атрымалася абнавіць канал." }, { "id": "api.channel.patch_channel_moderations.license.error", - "translation": "Ваша ліцэнзія не падтрымлівае мадэрацыю канала" + "translation": "Ваша ліцэнзія не падтрымлівае мадэрацыю каналаў" }, { "id": "api.channel.move_channel.type.invalid", - "translation": "Немагчыма перамясціць каналы асабістых паведамленняў ці групавыя каналы." + "translation": "Нельга перамясціць прамыя каналы або каналы групавых паведамленняў" }, { "id": "api.channel.leave.left", - "translation": "%v пакінуў канал." + "translation": "%v пакінуў(ла) канал." }, { "id": "api.channel.leave.direct.app_error", - "translation": "Немагчыма пакінуць канал асабістых паведамленняў." + "translation": "Нельга пакінуць канал прамых паведамленняў." }, { "id": "api.channel.leave.default.app_error", - "translation": "Немагчыма пакінуць канал па змаўчанні {{.Channel}}." + "translation": "Нельга пакінуць канал па змаўчанні {{.Channel}}." }, { "id": "api.channel.join_channel.post_and_forget", - "translation": "%v далучыўся да канала." + "translation": "%v далучыўся(лася) да канала." }, { "id": "api.channel.join_channel.permissions.app_error", - "translation": "У вас няма адпаведных правоў." + "translation": "У вас няма адпаведных дазволаў." }, { "id": "api.channel.guest_join_channel.post_and_forget", - "translation": "%v далучыўся да канала як госць." + "translation": "%v далучыўся(лася) да канала як госць." }, { "id": "api.channel.get_channel_moderations.license.error", - "translation": "Ваша ліцэнзія не падтрымлівае мадэрацыю канала" + "translation": "Ваша ліцэнзія не падтрымлівае мадэрацыю каналаў" }, { "id": "api.channel.delete_channel.type.invalid", - "translation": "Немагчыма выдаліць прамыя ці групавыя каналы паведамленняў" + "translation": "Нельга выдаліць прамыя каналы або каналы групавых паведамленняў" }, { "id": "api.channel.delete_channel.deleted.app_error", - "translation": "Дадзены канал быў перамешчаны ў архіў, альбо выдалены." + "translation": "Канал быў заархіваваны або выдалены." }, { "id": "api.channel.delete_channel.cannot.app_error", - "translation": "Немагчыма выдаліць канал па змаўчанні {{.Channel}}." + "translation": "Нельга выдаліць канал па змаўчанні {{.Channel}}." }, { "id": "api.channel.delete_channel.archived", - "translation": "%v архіваваў канал." + "translation": "%v заархіваваў канал." }, { "id": "api.channel.create_group.bad_user.app_error", - "translation": "Адзін з прапанаваных карыстальнікаў не існуе." + "translation": "Адзін з пададзеных карыстальнікаў не існуе." }, { "id": "api.channel.create_group.bad_size.app_error", - "translation": "Канал групы карыстальнікаў павінен змяшчаць не менш за 3 і не больш за 8 карыстальнікаў." + "translation": "Групавыя каналы паведамленняў павінны ўтрымліваць не менш за 3 і не больш за 8 карыстальнікаў." }, { "id": "api.channel.create_direct_channel.invalid_user.app_error", - "translation": "Няправільны ідэнтыфікатар карыстальніка пры стварэнні канала асабістых паведамленняў." + "translation": "Няправільны ідэнтыфікатар карыстальніка для стварэння прамога канала." }, { "id": "api.channel.create_channel.max_channel_limit.app_error", - "translation": "Нельга стварыць больш за {{.MaxChannelsPerTeam}} каналаў для гэтай каманды." + "translation": "Немагчыма стварыць больш за {{.MaxChannelsPerTeam}} каналаў для гэтай каманды." }, { "id": "api.channel.create_channel.direct_channel.team_restricted_error", - "translation": "Паміж гэтымі карыстальнікамі немагчыма стварыць прамы канал, бо ў іх няма агульнай каманды." + "translation": "Прамы канал не можа быць створаны паміж гэтымі карыстальнікамі, бо яны не маюць агульнай каманды." }, { "id": "api.channel.create_channel.direct_channel.app_error", - "translation": "Трэба выкарыстоўваць createDirectChannel з API для стварэння канала асабістых паведамленняў." + "translation": "Неабходна выкарыстоўваць службу API createDirectChannel для стварэння канала прамых паведамленняў." }, { "id": "api.channel.channel_member_counts_by_group.license.error", @@ -7197,155 +7201,155 @@ }, { "id": "api.channel.change_channel_privacy.public_to_private", - "translation": "Канал пераўтвораны ў прыватны." + "translation": "Гэты канал быў ператвораны ў прыватны канал." }, { "id": "api.channel.change_channel_privacy.private_to_public", - "translation": "Канал пераўтвораны ў публічны і да яго можа далучыцца любы ўдзельнік." + "translation": "Гэты канал быў ператвораны ў публічны канал, і да яго можа далучыцца любы член каманды." }, { "id": "api.channel.add_user_to_channel.type.app_error", - "translation": "Не магу дадаць карыстальніка ў канал гэтага тыпу." + "translation": "Нельга дадаць карыстальніка ў гэты тып канала." }, { "id": "api.channel.add_user.to.channel.failed.deleted.app_error", - "translation": "Немагчыма дадаць карыстальніка да канала, бо ён быў выдалены з каманды." + "translation": "Не атрымалася дадаць карыстальніка ў канал, бо ён быў выдалены з каманды." }, { "id": "api.channel.add_user.to.channel.failed.app_error", - "translation": "Не ўдалося дадаць карыстальніка ў канал." + "translation": "Не атрымалася дадаць карыстальніка ў канал." }, { "id": "api.channel.add_members.user_denied", - "translation": "Удзел у канале забаронены наступным карыстальнікам з-за абмежаванняў групы: {{ .UserIDs }}" + "translation": "Доступ да канала адмоўлены наступным карыстальнікам з-за абмежаванняў групы: {{ .UserIDs }}" }, { "id": "api.channel.add_members.error", - "translation": "Памылка пры дабаўленні ўдзельніка(ў) каналаў." + "translation": "Памылка дадання ўдзельніка(аў) канала." }, { "id": "api.channel.add_member.added", - "translation": "%v дададзены(а) у канал карыстальнікам %v." + "translation": "%v дададзены ў канал карыстальнікам %v." }, { "id": "api.channel.add_guest.added", - "translation": "%v дададзены(а) ў канал як госць карыстальнікам %v." + "translation": "%v дададзены ў канал як госць карыстальнікам %v." }, { "id": "api.bot.teams_channels.add_message_mobile", - "translation": "Калі ласка, дадайце мяне ў каманды і каналы, з якімі мне трэба ўзаемадзейнічаць. Гэта можна зрабіць у браўзэры або ў дадатку Mattermost для ПК." + "translation": "Калі ласка, дадайце мяне ў каманды і каналы, з якімі вы хочаце, каб я ўзаемадзейнічаў. Для гэтага выкарыстоўвайце браўзер або праграму Mattermost для ПК." }, { "id": "api.bot.create_disabled", - "translation": "Стварэнне робатаў было адключана." + "translation": "Стварэнне ботаў адключана." }, { "id": "api.admin.upload_brand_image.too_large.app_error", - "translation": "Немагчыма загрузіць файл. Ён занадта вялікі." + "translation": "Немагчыма загрузіць файл. Файл занадта вялікі." }, { "id": "api.admin.upload_brand_image.storage.app_error", - "translation": "Немагчыма загрузіць выяву. Сховішча выяў не наладжана." + "translation": "Немагчыма загрузіць выяву. Захоўванне выяў не наладжана." }, { "id": "api.admin.upload_brand_image.parse.app_error", - "translation": "Не ўдалося разабраць складовую форму." + "translation": "Не атрымалася разабраць шматчасткавую форму." }, { "id": "api.admin.upload_brand_image.no_file.app_error", - "translation": "Няма файла 'image' у запыце." + "translation": "У запыце няма файла з назвай 'image'." }, { "id": "api.admin.upload_brand_image.array.app_error", - "translation": "Пусты масіў 'image' у запыце." + "translation": "Пусты масіў у раздзеле 'image' у запыце." }, { "id": "api.admin.test_s3.missing_s3_bucket", - "translation": "Патрабуецца S3 Bucket" + "translation": "S3 Bucket неабходны" }, { "id": "api.admin.test_email.subject", - "translation": "Mattermost - Тэставанне настроек электроннай пошты" + "translation": "Mattermost - Праверка налад электроннай пошты" }, { "id": "api.templates.deactivate_body.title", - "translation": "Ваш рахунак быў дэактываваны на {{.ServerURL}}" + "translation": "Ваш уліковы запіс быў дэактываваны на {{ .ServerURL }}" }, { "id": "api.templates.deactivate_body.info", - "translation": "Вы адключылі свой уліковы запіс на {{.SiteURL}}." + "translation": "Вы дэактывавалі свой уліковы запіс на {{ .SiteURL }}." }, { "id": "api.templates.cloud_welcome_email.title", - "translation": "Ваша 14-дзённая пробная версія {{.WorkSpace}} гатова да працы!" + "translation": "Ваша працоўнае месца гатова да працы!" }, { "id": "api.templates.cloud_welcome_email.subtitle_info", - "translation": "Выканайце наступныя крокі, каб пабудаваць свае каманды і атрымаць максімальную аддачу ад сваёй працоўнай прасторы." + "translation": "Выканайце наступныя крокі, каб стварыць свае каманды і атрымаць максімальную карысць ад вашага працоўнага месца." }, { "id": "api.templates.cloud_welcome_email.subtitle", - "translation": "Наладзьце вашу працоўную прастору" + "translation": "Наладзьце сваё працоўнае месца" }, { "id": "api.templates.cloud_welcome_email.start_questions", - "translation": "У вас ёсць пытанні аб тым, як пачаць? Напішыце нам па адрасе" + "translation": "Ёсць пытанні па пачатку працы? Напішыце нам на" }, { "id": "api.templates.cloud_welcome_email.signin_sub_info2", - "translation": "для зручнейшай працы на Windows, Linux Mac, iOS і Android." + "translation": "для найлепшага досведу працы на Windows, Linux, Mac, iOS і Android." }, { "id": "api.templates.cloud_welcome_email.signin_sub_info", - "translation": "Увайдзіце ў сваю працоўную прастору на нашым" + "translation": "Увайдзіце ў сваё працоўнае месца на нашым" }, { "id": "api.templates.cloud_welcome_email.mm_apps", - "translation": "мабільныя і настольныя прыкладанні" + "translation": "мабільныя і настольныя праграмы" }, { "id": "api.templates.cloud_welcome_email.invite_sub_info", - "translation": "Падзяліцеся гэтай спасылкай, каб запрасіць новых удзельнікаў далучыцца ў {{.WorkSpace}}:" + "translation": "Падзяліцеся гэтай спасылкай, каб запрасіць вашых членаў далучыцца да {{.WorkSpace}}:" }, { "id": "api.templates.cloud_welcome_email.invite_info", - "translation": "Запрасіць у працоўную прастору" + "translation": "Запрасіце людзей у ваша працоўнае месца" }, { "id": "api.templates.cloud_welcome_email.info2", - "translation": "Абавязкова захавайце спасылку ці дадайце яе ў закладкі для выкарыстання ў будучыні." + "translation": "Пераканайцеся, што вы захавалі або дадалі ў закладкі сваю спасылку для будучага выкарыстання." }, { "id": "api.templates.cloud_welcome_email.download_mm_info", - "translation": "Спампаваць Mattermost App" + "translation": "Спампаваць праграму Mattermost" }, { "id": "api.templates.cloud_welcome_email.add_apps_sub_info", - "translation": "Аптымізуе сваю працу з такімі інструментамі, як Github, Jira and Zoom. Праглядзіце ўсе інтэграцыі, якія ёсць у нас у нашым" + "translation": "Аптымізуйце сваю працу з такімі інструментамі, як GitHub, Jira і Zoom. Даследуйце ўсе інтэграцыі, якія мы маем на нашым" }, { "id": "api.templates.cloud_welcome_email.add_apps_info", - "translation": "Дадаць прыкладання ў вашу працоўную прастору" + "translation": "Дадайце праграмы ў ваша працоўнае месца" }, { "id": "api.team.update_team_scheme.scheme_scope.error", - "translation": "Немагчыма ўсталяваць схему каманды бо дадзеная схема не з'яўляецца схемай каманды." + "translation": "Немагчыма ўсталяваць схему для каманды, бо прадастаўленая схема не з'яўляецца каманднай." }, { "id": "api.team.update_team_scheme.license.error", - "translation": "Ваша ліцэнзія не падтрымлівае змену схемы каманды" + "translation": "Ваша ліцэнзія не падтрымлівае абнаўленне схемы каманды" }, { "id": "api.team.update_team_member_roles.guest_and_user.app_error", - "translation": "Недапушчальнае абнаўленне ўдзельніка каманды: карыстальнік павінен быць госцем ці карыстальнікам, але не абодвума." + "translation": "Няправільнае абнаўленне члена каманды: карыстальнік павінен быць гостем або карыстальнікам, але не абодвума." }, { "id": "api.team.update_restricted_domains.mismatch.app_error", - "translation": "Абмежаванні Каманды на дамене {{.Domain}} забаронены ў наладах сістэмы. Калі ласка, звярніцеся да сістэмнага адміністратара." + "translation": "Абмежаванне каманды для {{ .Domain }} не дазволена канфігурацыяй сістэмы. Калі ласка, звярніцеся да вашага сістэмнага адміністратара." }, { "id": "api.team.update_member_roles.not_a_member", - "translation": "Указаны карыстальнік не з'яўляецца ўдзельнікам названай групы." + "translation": "Указаны карыстальнік не з'яўляецца членам указанай каманды." }, { "id": "api.team.team_icon.update.app_error", @@ -7353,55 +7357,55 @@ }, { "id": "api.team.set_team_icon.write_file.app_error", - "translation": "Не ўдалося захаваць значок каманды." + "translation": "Немагчыма захаваць значок каманды." }, { "id": "api.team.set_team_icon.too_large.app_error", - "translation": "Немагчыма загрузіць выяву. Файл занадта вялікі." + "translation": "Немагчыма загрузіць значок каманды. Файл занадта вялікі." }, { "id": "api.team.set_team_icon.storage.app_error", - "translation": "Не ўдалося загрузіць файл. Сховішча малюнкаў не наладжана." + "translation": "Немагчыма загрузіць значок каманды. Сховішча выяваў не наладжана." }, { "id": "api.team.set_team_icon.parse.app_error", - "translation": "Не ўдалося разабраць складовую форму." + "translation": "Немагчыма разабраць шматчасткавую форму." }, { "id": "api.team.set_team_icon.open.app_error", - "translation": "Не атрымалася адкрыць файл выявы." + "translation": "Немагчыма адкрыць файл выявы." }, { "id": "api.team.set_team_icon.no_file.app_error", - "translation": "Няма файла 'image' у запыце." + "translation": "Няма файла пад 'image' у запыце." }, { "id": "api.team.set_team_icon.get_team.app_error", - "translation": "Адбылася памылка пры атрыманні Каманды." + "translation": "Адбылася памылка пры атрыманні каманды." }, { "id": "api.team.set_team_icon.encode.app_error", - "translation": "Не ўдалося закадзіраваць значок каманды." + "translation": "Немагчыма закадаваць значок каманды." }, { "id": "api.team.set_team_icon.decode.app_error", - "translation": "Не ўдалося дэкадаваць значок каманды." + "translation": "Немагчыма дэкадаваць значок каманды." }, { "id": "api.team.set_team_icon.check_image_limits.app_error", - "translation": "Памылка праверкі абмежаванняў выявы. Дазвол занадта высокае." + "translation": "Праверка абмежаванняў выявы не ўдалася. Дазвол занадта высокі." }, { "id": "api.team.set_team_icon.array.app_error", - "translation": "Пусты масіў 'image' у запыце." + "translation": "Пусты масіў пад 'image' у запыце." }, { "id": "api.team.search_teams.pagination_not_implemented.public_team_search", - "translation": "Пагінацыя не выконваецца для пошуку ў публічных групах." + "translation": "Пагінацыя не рэалізавана для пошуку толькі ў публічных камандах." }, { "id": "api.team.search_teams.pagination_not_implemented.private_team_search", - "translation": "Пагінацыя не выконваецца для пошуку ў прыватных групах." + "translation": "Пагінацыя не рэалізавана для пошуку толькі ў прыватных камандах." }, { "id": "api.team.remove_user_from_team.removed", @@ -7409,31 +7413,31 @@ }, { "id": "api.team.remove_user_from_team.missing.app_error", - "translation": "Карыстальнік неактыўны для ўдзелу ў гэтай камандзе." + "translation": "Карыстальнік не з'яўляецца часткай гэтай каманды." }, { "id": "api.team.remove_team_icon.get_team.app_error", - "translation": "Адбылася памылка пры атрыманні Каманды." + "translation": "Адбылася памылка пры атрыманні каманды." }, { "id": "api.team.remove_member.group_constrained.app_error", - "translation": "Немагчыма выдаліць карыстальніка з кіраванай групамі каманды." + "translation": "Немагчыма выдаліць карыстальніка з каманды, абмежаванай групай." }, { "id": "api.team.move_channel.success", - "translation": "Канал перайшоў у гэтую каманду з %v." + "translation": "Гэты канал быў перамешчаны ў гэтую каманду з %v." }, { "id": "api.team.move_channel.post.error", - "translation": "Не атрымалася апублікаваць паведамленне перасоўвання канала." + "translation": "Не атрымалася адправіць паведамленне аб перамяшчэнні канала." }, { "id": "api.team.leave.left", - "translation": "%v пакінуў(ла) каманду." + "translation": "%v пакінуў каманду." }, { "id": "api.team.join_user_to_team.allowed_domains.app_error", - "translation": "E-mail павінен належаць рэальнаму дамену (напрыклад @example.com). Калі ласка, звярніцеся да сістэмнага адміністратара па дадатковую інфармацыю." + "translation": "Карыстальнік не можа быць дададзены, бо дамен, звязаны з уліковым запісам, не дазволены. Звярніцеся да вашага сістэмнага адміністратара для атрымання дадатковых дэталяў." }, { "id": "api.team.join_team.post_and_forget", @@ -7441,63 +7445,63 @@ }, { "id": "api.team.is_team_creation_allowed.domain.app_error", - "translation": "E-mail павінен належаць рэальнаму дамену (напрыклад @example.com). Калі ласка, звярніцеся да сістэмнага адміністратара па дадатковую інфармацыю." + "translation": "Карыстальнік не можа быць дададзены, бо дамен, звязаны з уліковым запісам, не дазволены. Звярніцеся да вашага сістэмнага адміністратара для атрымання дадатковых дэталяў." }, { "id": "api.team.is_team_creation_allowed.disabled.app_error", - "translation": "Магчымасць стварэння каманд была адключаная. Калі ласка, звярніцеся да вашага сістэмнага адміністратара па падрабязнасці." + "translation": "Стварэнне каманд адключана. Калі ласка, звярніцеся да вашага сістэмнага адміністратара для атрымання дэталяў." }, { "id": "api.team.invite_members.no_one.app_error", - "translation": "Няма нікога для запрашэння." + "translation": "Няма каго запрашаць." }, { "id": "api.team.invite_members.invalid_email.app_error", - "translation": "Гэты адрас паштовай скрыні не належыць спісу дазволеных даменаў: {{.Addresses}}. Калі ласка, звярніцеся да Вашага сістэмнага адміністратара па інфармацыю." + "translation": "Наступныя адрасы электроннай пошты не належаць да прынятай дамена: {{.Addresses}}. Калі ласка, звярніцеся да вашага сістэмнага адміністратара для атрымання дэталяў." }, { "id": "api.team.invite_members.disabled.app_error", - "translation": "Запрашэнні па электроннай пошце адключаныя." + "translation": "Запрашэнні па электроннай пошце адключаны." }, { "id": "api.team.invite_guests_to_channels.invalid_body.app_error", - "translation": "Цела запыту некарэктнае ці адсутнічае." + "translation": "Несапраўднае або адсутнае цела запыту." }, { "id": "api.team.invite_guests.channel_in_invalid_team.app_error", - "translation": "Каналы ў запрашэнні павінны быць часткай каманды ў запрашэнні." + "translation": "Каналы запрашэння павінны быць часткай каманды запрашэння." }, { "id": "api.team.invalidate_all_email_invites.app_error", - "translation": "Памылка анулявання e-mail запрашэнняў." + "translation": "Памылка анулявання запрашэнняў па электроннай пошце." }, { "id": "api.team.import_team.unavailable.app_error", - "translation": "Няправільны запыт: адсутнічае поле \"filesize\"." + "translation": "Некарэктны запыт: поле filesize адсутнічае." }, { "id": "api.team.import_team.parse.app_error", - "translation": "Не ўдалося разабраць складовую форму." + "translation": "Немагчыма разабраць шматчасткавую форму." }, { "id": "api.team.import_team.open.app_error", - "translation": "Не магу адкрыць файл." + "translation": "Немагчыма адкрыць файл." }, { "id": "api.team.import_team.no_import_from.app_error", - "translation": "Няправільны запыт: адсутнічае поле \"importFrom\"." + "translation": "Некарэктны запыт: поле importFrom адсутнічае." }, { "id": "api.team.import_team.no_file.app_error", - "translation": "Няма файла 'file' у запыце." + "translation": "Няма файла пад 'file' у запыце." }, { "id": "api.team.import_team.integer.app_error", - "translation": "Памер файла не з'яўляецца цэлым." + "translation": "Памер файла не з'яўляецца цэлым лікам." }, { "id": "api.team.import_team.array.app_error", - "translation": "Пусты масіў 'file' у запыце." + "translation": "Пусты масіў пад 'file' у запыце." }, { "id": "api.team.get_team_icon.read_file.app_error", @@ -7505,15 +7509,15 @@ }, { "id": "api.team.get_team_icon.filesettings_no_driver.app_error", - "translation": "Няправільнае імя драйвера ў наладах файлаў. Павінна быць 'local' ці 'amazons3'." + "translation": "Няправільнае імя драйвера для налад файлаў. Павінна быць 'local' або 'amazons3'." }, { "id": "api.team.get_invite_info.not_open_team", - "translation": "Запрашэнне недапушчальна, бо каманда не з'яўляецца адкрытай." + "translation": "Запрашэнне несапраўднае, бо гэта не адкрытая каманда." }, { "id": "api.team.get_all_teams.insufficient_permissions", - "translation": "У вас няма правоў для атрымання спісу ўсіх каманд" + "translation": "У вас няма адпаведных дазволаў для спісу ўсіх каманд" }, { "id": "api.team.demote_user_to_guest.license.error", @@ -7521,67 +7525,67 @@ }, { "id": "api.team.demote_user_to_guest.disabled.error", - "translation": "Гасцявыя ўліковыя запісы адключаныя." + "translation": "Гасцявыя ўліковыя запісы адключаны." }, { "id": "api.team.add_user_to_team_from_invite.guest.app_error", - "translation": "Гасцям абмежавана магчымасць далучацца да каманды праз спасылку-запрашэнне. Запытайце гасцёўня запрашэнне па электроннай пошце для далучэння да каманды." + "translation": "Гасцям забаронена далучацца да каманды па спасылцы-запрашэнні. Калі ласка, запытайце гасцявое запрашэнне па электроннай пошце ў каманду." }, { "id": "api.team.add_user_to_team.missing_parameter.app_error", - "translation": "Для дадання карыстальніка ў каманду патрабуецца пазначыць параметр." + "translation": "Параметр, неабходны для дадання карыстальніка ў каманду." }, { "id": "api.team.add_user_to_team.added", - "translation": "%v дададзены(а) у каманду карыстальнікам %v." + "translation": "%v дададзены ў каманду карыстальнікам %v." }, { "id": "api.team.add_team_member.invalid_body.app_error", - "translation": "Немагчыма прааналізаваць цела запыту." + "translation": "Немагчыма разабраць цела запыту." }, { "id": "api.team.add_members.user_denied", - "translation": "Гэтая каманда кіруецца групамі. Гэты карыстач не з'яўляецца часткай групы, якая сінхранізуецца з гэтай камандай." + "translation": "Гэтай камандай кіруюць групы. Гэты карыстальнік не з'яўляецца часткай групы, якая сінхранізуецца з гэтай камандай." }, { "id": "api.team.add_members.error", - "translation": "Памылка пры даданні ўдзельніка каманды." + "translation": "Памылка дадання члена(оў) каманды." }, { "id": "api.system.update_viewed_notices.failed", - "translation": "Абнаўленне прагледжаных апавяшчэнняў не ўдалося" + "translation": "Не атрымалася абнавіць прагледжаныя апавяшчэнні" }, { "id": "api.system.update_notices.validating_failed", - "translation": "Не ўдалося праверыць умовы апавяшчэння аб прадукце" + "translation": "Не атрымалася праверыць умовы апавяшчэння аб прадукце" }, { "id": "api.system.update_notices.parse_failed", - "translation": "Не ўдалося атрымаць апавяшчэння аб прадукце" + "translation": "Не атрымалася разабраць апавяшчэнні аб прадукце" }, { "id": "api.system.update_notices.fetch_failed", - "translation": "Не ўдалося атрымаць апавяшчэння аб прадукце" + "translation": "Не атрымалася атрымаць апавяшчэнні аб прадукце" }, { "id": "api.system.update_notices.clear_failed", - "translation": "Не атрымалася выдаліць старыя апавяшчэнні аб прадукце" + "translation": "Не атрымалася ачысціць старыя апавяшчэнні аб прадукце" }, { "id": "api.system.id_loaded.not_available.app_error", - "translation": "Push-паведамлення з усталяваным ідэнтыфікатарам (ID Loaded) не сканфігураваны ці не падтрымліваюцца на гэтым серверы." + "translation": "Push-апавяшчэнні з ID Loaded не наладжаны або не падтрымліваюцца на гэтым серверы." }, { "id": "api.slackimport.slack_import.zip.file_too_large", - "translation": "{{.Filename}} у zip архіве занадта вялікі для апрацоўкі імпарцёрам з Slack\n" + "translation": "{{.Filename}} у zip-архіве занадта вялікі для апрацоўкі для імпарту Slack\n" }, { "id": "api.slackimport.slack_import.zip.app_error", - "translation": "Немагчыма адкрыць zip-архіў якія экспартуюцца з Slack дадзеных.\n" + "translation": "Немагчыма адкрыць zip-файл экспарту Slack.\n" }, { "id": "api.slackimport.slack_import.team_fail", - "translation": "Не ўдалося атрымаць каманду, у якую неабходна імпартаваць.\n" + "translation": "Немагчыма атрымаць каманду для імпарту.\n" }, { "id": "api.slackimport.slack_import.open.app_error", @@ -7589,107 +7593,107 @@ }, { "id": "api.slackimport.slack_import.note3", - "translation": "- Дадатковыя памылкі могуць быць знойдзены ў часопісах сервера.\n" + "translation": "- Дадатковыя памылкі можна знайсці ў часопісах сервера.\n" }, { "id": "api.slackimport.slack_import.note2", - "translation": "- Паведамленні ботаў Slack на дадзены момант не падтрымліваюцца.\n" + "translation": "- Паведамленні ботаў Slack у цяперашні час не падтрымліваюцца.\n" }, { "id": "api.slackimport.slack_import.note1", - "translation": "- Некаторыя паведамленні маглі быць не імпартаваныя, паколькі не падтрымліваюцца гэтым іпарцёрам.\n" + "translation": "- Некаторыя паведамленні маглі не быць імпартаваныя, бо яны не падтрымліваюцца гэтым імпартэрам.\n" }, { "id": "api.slackimport.slack_import.log", - "translation": "Лог імпарту Mattermost Slack\n" + "translation": "Часопіс імпарту Mattermost Slack\n" }, { "id": "api.slackimport.slack_add_users.unable_import", - "translation": "Не ўдалося імпартаваць карыстальніка: {{.Username}}.\n" + "translation": "Немагчыма імпартаваць карыстальніка Slack: {{.Username}}.\n" }, { "id": "api.slackimport.slack_add_users.missing_email_address", - "translation": "Карыстальнік {{.Username}} не мае адрасы электроннай пошты ў экспарце з Slack. Выкарыстоўваецца {{.Email}} як заглушка. Карыстальнік павінен абнавіць адрас электроннай пошты пры ўваходзе ў сістэму.\n" + "translation": "У карыстальніка {{.Username}} няма адраса электроннай пошты ў экспарце Slack. Выкарыстоўваецца {{.Email}} як запаўняльнік. Карыстальнік павінен абнавіць свой адрас электроннай пошты пасля ўваходу ў сістэму.\n" }, { "id": "api.slackimport.slack_add_users.merge_existing_failed", - "translation": "Карыстальнік Slack аб'яднаны з існуючым карыстальнікам Mattermost па супадзенні email {{.Email}} і імя карыстальніка {{.Username}}, але было немагчыма дадаць карыстальніка ў яго каманду.\n" + "translation": "Карыстальнік Slack аб'яднаны з існуючым карыстальнікам Mattermost з адпаведнымі электроннай поштай {{.Email}} і імем карыстальніка {{.Username}}, але не атрымалася дадаць карыстальніка ў яго каманду.\n" }, { "id": "api.slackimport.slack_add_users.merge_existing", - "translation": "Slack карыстальнік зліўся з існуючым карыстальнікам Mattermost з адпаведным адрасам электроннай пошты {{.Email}} і імем карыстальніка {{.Username}}.\n" + "translation": "Карыстальнік Slack аб'яднаны з існуючым карыстальнікам Mattermost з адпаведнымі электроннай поштай {{.Email}} і імем карыстальніка {{.Username}}.\n" }, { "id": "api.slackimport.slack_add_users.email_pwd", - "translation": "Slack карыстальнік з email {{.Email}} і паролем {{.Password}} імпартаваны.\n" + "translation": "Карыстальнік Slack з электроннай поштай {{.Email}} і паролем {{.Password}} быў імпартаваны.\n" }, { "id": "api.slackimport.slack_add_channels.merge", - "translation": "Slack канал {{.DisplayName}} ужо існуе ў выглядзе канала Mattermost. Выраблена зліццё.\n" + "translation": "Канал Slack {{.DisplayName}} ужо існуе як актыўны канал Mattermost. Абодва каналы былі аб'яднаны.\n" }, { "id": "api.slackimport.slack_add_channels.import_failed", - "translation": "Не ўдалося імпартаваць Slack канал {{.DisplayName}}.\n" + "translation": "Немагчыма імпартаваць канал Slack {{.DisplayName}}.\n" }, { "id": "api.slackimport.slack_add_channels.failed_to_add_user", - "translation": "Не атрымалася дадаць карыстальніка Slack {{.Username}} у канал.\n" + "translation": "Немагчыма дадаць карыстальніка Slack {{.Username}} у канал.\n" }, { "id": "api.slackimport.slack_add_bot_user.unable_import", - "translation": "Не ўдалося імпартаваць карыстальніка Integration / Slack Bot {{.Username}}.\n" + "translation": "Немагчыма імпартаваць карыстальніка Integration/Slack Bot {{.Username}}.\n" }, { "id": "api.slackimport.slack_add_bot_user.email_pwd", - "translation": "Slack карыстальнік з email {{.Email}} і паролем {{.Password}} імпартаваны.\n" + "translation": "Карыстальнік Integration/Slack Bot з электроннай поштай {{.Email}} і паролем {{.Password}} быў імпартаваны.\n" }, { "id": "api.admin.test_email.reenter_password", - "translation": "SMTP сервер, порт ці імя карыстальніка зменены. Калі ласка, паўторна ўвядзіце пароль SMTP для праверкі злучэння." + "translation": "Змяніліся SMTP-сервер, порт або імя карыстальніка. Калі ласка, увядзіце пароль SMTP яшчэ раз, каб праверыць злучэнне." }, { "id": "api.admin.test_email.missing_server", - "translation": "Патрабуецца SMTP сервер" + "translation": "SMTP-сервер неабходны" }, { "id": "api.admin.test_email.body", - "translation": "Здаецца, ваша электронная пошта Mattermost наладжаная правільна!" + "translation": "Здаецца, ваша электронная пошта Mattermost наладжана правільна!" }, { "id": "api.admin.saml.not_available.app_error", - "translation": "SAML 2.0 не наладжаны ці не падтрымліваецца на гэтым серверы." + "translation": "SAML 2.0 не наладжаны або не падтрымліваецца на гэтым серверы." }, { "id": "api.admin.saml.metadata.app_error", - "translation": "Адбылася памылка пры пабудове метададзеных пастаўшчыка паслуг." + "translation": "Адбылася памылка пры стварэнні метаданых пастаўшчыка паслуг." }, { "id": "api.admin.saml.invalid_xml_missing_ssoservices.app_error", - "translation": "Адсутнічае вузел паслуг SSO пастаўшчыка паслугі ідэнтыфікацыі ў XML." + "translation": "У XML адсутнічае вузел SSO-службаў пастаўшчыка ідэнтыфікацыйных даных." }, { "id": "api.admin.saml.invalid_xml_missing_keydescriptor.app_error", - "translation": "Адсутнічае вузел дэскрыптараў ключоў пастаўшчыка паслугі ідэнтыфікацыі ў XML." + "translation": "У XML адсутнічае вузел апісальнікаў ключа пастаўшчыка ідэнтыфікацыйных даных." }, { "id": "api.admin.saml.invalid_xml_missing_idpssodescriptors.app_error", - "translation": "Адсутнічае вузел дэскрыптараў SSO пастаўшчыка паслугі ідэнтыфікацыі ў XML." + "translation": "У XML адсутнічае вузел SSO-апісальнікаў пастаўшчыка ідэнтыфікацыйных даных." }, { "id": "api.admin.saml.failure_reset_authdata_to_email.app_error", - "translation": "Не ўдалося скінуць поле AuthData на Email." + "translation": "Не атрымалася скінуць поле AuthData на Email." }, { "id": "api.admin.saml.failure_parse_idp_certificate.app_error", - "translation": "Выяўлена памылка пры разборы інфармацыі метададзеных, атрыманай ад пастаўшчыка паслугі ідэнтыфікацыі ў сертыфікат." + "translation": "Памылка пры парсінгу інфармацыі метаданых, атрыманай ад пастаўшчыка ідэнтыфікацыйных даных, для сертыфіката." }, { "id": "api.admin.saml.failure_get_metadata_from_idp.app_error", - "translation": "Не ўдалося атрымаць метададзеныя з URL-адраса пастаўшчыка паслугі ідэнтыфікацыі." + "translation": "Не атрымалася атрымаць метаданыя з URL пастаўшчыка ідэнтыфікацыйных даных." }, { "id": "api.admin.remove_certificate.delete.app_error", - "translation": "Узнікла памылка пры выдаленні сертыфіката." + "translation": "Адбылася памылка пры выдаленні сертыфіката." }, { "id": "api.admin.get_brand_image.storage.app_error", @@ -7701,47 +7705,47 @@ }, { "id": "api.admin.add_certificate.no_file.app_error", - "translation": "Няма файла пад атрыбутам 'certificate' у запыце." + "translation": "У запыце адсутнічае файл у полі 'certificate'." }, { "id": "api.admin.add_certificate.array.app_error", - "translation": "Няма файла пад атрыбутам 'certificate' у запыце." + "translation": "У запыце няма файла з назвай 'certificate'." }, { "id": "api.email_batching.send_batched_email_notification.subTitle", - "translation": "Глядзіце ніжэй зводку Вашых новых паведамленняў." + "translation": "Глядзіце ніжэй зводку вашых новых паведамленняў." }, { "id": "api.email_batching.add_notification_email_to_batch.disabled.app_error", - "translation": "Паштовыя аб'яднанні адключаныя сістэмным адміністратарам." + "translation": "Пакетная апрацоўка электроннай пошты адключана сістэмным адміністратарам." }, { "id": "api.email_batching.add_notification_email_to_batch.channel_full.app_error", - "translation": "Канал-атрымальнік паштовага аб'яднання быў поўны. Калі ласка, павялічце значэнне EmailBatchingBufferSize." + "translation": "Канал прыёму для пакетнай апрацоўкі электроннай пошты быў поўны. Калі ласка, павялічце EmailBatchingBufferSize." }, { "id": "api.custom_status.recent_custom_statuses.delete.app_error", - "translation": "Не ўдалося выдаліць апошні статус. Паспрабуйце спачатку дадаць статус або зьвяжыцеся з адміністратарам для атрымання падрабязнай інфармацыі." + "translation": "Немагчыма выдаліць апошні статус. Калі ласка, паспрабуйце спачатку дадаць статус або звярніцеся да вашага сістэмнага адміністратара для атрымання падрабязнасцей." }, { "id": "api.custom_status.disabled", - "translation": "Функцыя карыстацкага статута адключаная. Калі ласка, звяжыцеся з адміністратарам для больш падрабязнай інфармацыі." + "translation": "Функцыя карыстальніцкага статусу адключана. Калі ласка, звярніцеся да вашага сістэмнага адміністратара для атрымання падрабязнасцей." }, { "id": "api.create_terms_of_service.empty_text.app_error", - "translation": "Увядзіце тэкст для сваіх умоў абслугоўвання." + "translation": "Калі ласка, увядзіце тэкст для вашых карыстальніцкіх умоў абслугоўвання." }, { "id": "api.create_terms_of_service.custom_terms_of_service_disabled.app_error", - "translation": "Карыстальніцкія ўмовы абслугоўвання адключаныя." + "translation": "Функцыя карыстальніцкіх умоў абслугоўвання адключана." }, { "id": "api.context.token_provided.app_error", - "translation": "Сесія не OAuth, але токен прысутнічае ў радку запыту." + "translation": "Сесія не з'яўляецца OAuth, але токен быў прадастаўлены ў радке запыту." }, { "id": "api.context.session_expired.app_error", - "translation": "Няверная ці скончаная сесія, калі ласка, увайдзіце зноў." + "translation": "Несапраўдны або пратэрмінаваны сеанс, калі ласка, увайдзіце зноў." }, { "id": "api.context.server_busy.app_error", @@ -7749,43 +7753,43 @@ }, { "id": "api.context.remote_id_missing.app_error", - "translation": "Адсутнічае ID бяспечнага злучэння." + "translation": "Адсутнічае ідэнтыфікатар бяспечнага злучэння." }, { "id": "api.context.remote_id_mismatch.app_error", - "translation": "Неадпаведнасць ID бяспечнага злучэння." + "translation": "Нясупадзенне ідэнтыфікатара бяспечнага злучэння." }, { "id": "api.context.remote_id_invalid.app_error", - "translation": "Немагчыма знайсці бяспечны ID злучэння {{.RemoteId}}." + "translation": "Немагчыма знайсці бяспечны ідэнтыфікатар злучэння {{.RemoteId}}." }, { "id": "api.context.permissions.app_error", - "translation": "У вас няма адпаведных правоў." + "translation": "Вы не маеце адпаведных дазволаў." }, { "id": "api.context.mfa_required.app_error", - "translation": "На гэтым серверы патрабуецца шматфактарная аўтарызацыя." + "translation": "Шматфактарная аўтэнтыфікацыя патрабуецца на гэтым серверы." }, { "id": "api.context.local_origin_required.app_error", - "translation": "Гэтая канчатковая кропка патрабуе лакальнай крыніцы запыту." + "translation": "Гэты эндпоінт патрабуе лакальнага паходжання запыту." }, { "id": "api.context.invalid_url_param.app_error", - "translation": "Няправільны ці прапушчаны параметр {{.Name}} у URL запыта." + "translation": "Некарэктны або адсутнічае параметр {{.Name}} у URL запыту." }, { "id": "api.context.invalid_token.error", - "translation": "Няправільны токен сесіі={{.Token}}, err={{.Error}}" + "translation": "Некарэктны токен сесіі={{.Token}}, памылка={{.Error}}" }, { "id": "api.context.invalid_body_param.app_error", - "translation": "Няправільна або прапушчана {{.Name}} у целе запыту." + "translation": "Некарэктны або адсутнічае {{.Name}} у целе запыту." }, { "id": "api.context.get_user.app_error", - "translation": "Немагчыма атрымаць карыстальніка з UserID сеансу." + "translation": "Немагчыма атрымаць карыстальніка з UserID сесіі." }, { "id": "api.context.404.app_error", @@ -7793,15 +7797,15 @@ }, { "id": "api.config.update_config.restricted_merge.app_error", - "translation": "Памылка аб'яднання з дадзенай канфігурацыяй." + "translation": "Немагчыма аб'яднаць дадзеную канфігурацыю." }, { "id": "api.config.update_config.not_allowed_security.app_error", - "translation": "Змена {{.Name}} не дазволена па меркаваннях бяспекі." + "translation": "Змена {{.Name}} не дазволена з прычын бяспекі." }, { "id": "api.config.update_config.diff.app_error", - "translation": "Не ўдалося правесці параўнанне канфігурацый" + "translation": "Немагчыма параўнаць канфігурацыі" }, { "id": "api.config.update_config.clear_siteurl.app_error", @@ -7809,31 +7813,31 @@ }, { "id": "api.config.reload_config.app_error", - "translation": "Не атрымалася перазагрузіць канфігурацыю." + "translation": "Немагчыма перазагрузіць канфігурацыю." }, { "id": "api.config.patch_config.restricted_merge.app_error", - "translation": "Памылка аб'яднання з дадзенай канфігурацыяй." + "translation": "Немагчыма аб'яднаць дадзеную канфігурацыю." }, { "id": "api.config.patch_config.diff.app_error", - "translation": "Не ўдалося правесці параўнанне канфігурацый" + "translation": "Немагчыма параўнаць канфігурацыі" }, { "id": "api.config.migrate_config.app_error", - "translation": "Не ўдалося перанесці сховішча канфігурацыі." + "translation": "Немагчыма перанесці сховішча канфігурацыі." }, { "id": "api.config.get_config.restricted_merge.app_error", - "translation": "Памылка аб'яднання з дадзенай канфігурацыяй." + "translation": "Немагчыма аб'яднаць дадзеную канфігурацыю." }, { "id": "api.command_shrug.desc", - "translation": "Дадаць ¯\\_(ツ)_/¯ у ваша паведамленне" + "translation": "Дадаць ¯\\_(ツ)_/¯ да вашага паведамлення" }, { "id": "api.command_shortcuts.unsupported.app_error", - "translation": "Гарачыя клавішы недаступныя на прыладзе." + "translation": "Каманда спалучэнняў клавіш не падтрымліваецца на вашай прыладзе." }, { "id": "api.command_shortcuts.desc", @@ -7841,7 +7845,7 @@ }, { "id": "api.command_share.unshare_channel.help", - "translation": "Адмяніць уласцівасць \"сумесны\" для бягучага канала" + "translation": "Адмяняе агульны доступ да бягучага канала" }, { "id": "api.command_share.unknown_action", @@ -7849,55 +7853,55 @@ }, { "id": "api.command_share.uninvite_remote_id.help", - "translation": "Ідэнтыфікатар бяспечнага падлучэння для адмены запрашэння." + "translation": "Ідэнтыфікатар бяспечнага злучэння для адмены запрашэння." }, { "id": "api.command_share.uninvite_remote.help", - "translation": "Адмена запрашэння на бяспечнае злучэнне ад гэтага сумеснага канала" + "translation": "Адмяняе запрашэнне бяспечнага злучэння з гэтага агульнага канала" }, { "id": "api.command_share.shared_channel_unshare.error", - "translation": "Не атрымалася адключыць абагульванне да гэтага канала: {{.Error}}." + "translation": "Немагчыма адмяніць агульны доступ да гэтага канала: {{.Error}}." }, { "id": "api.command_share.shared_channel_unavailable", - "translation": "Гэты канал больш не абагулены." + "translation": "Гэты канал больш не з'яўляецца агульным." }, { "id": "api.command_share.share_read_only.hint", - "translation": "[readonly] - 'Y' ці 'N'. Па змаўчанні 'N'" + "translation": "[толькі для чытання] - 'Y' або 'N'. Па змаўчанні 'N'" }, { "id": "api.command_share.share_read_only.help", - "translation": "Канал будзе абагулены ў рэжыме толькі для чытання" + "translation": "Канал будзе даступны толькі для чытання" }, { "id": "api.command_share.share_channel.error", - "translation": "Не атрымалася абагуліць гэты канал: {{.Error}}" + "translation": "Немагчыма зрабіць гэты канал агульным: {{.Error}}" }, { "id": "api.command_share.service_disabled", - "translation": "Служба сумесных каналаў адключана." + "translation": "Сэрвіс агульных каналаў адключаны." }, { "id": "api.command_share.remote_uninvited", - "translation": "Адмненена запрашэнне для бяспечнаго злучэння `{{.RemoteId}}`." + "translation": "Бяспечнае злучэнне `{{.RemoteId}}` адклікана." }, { "id": "api.command_share.remote_table_header", - "translation": "| Бяспечнае злучэнне | URL сайта | Толькі чытанне | Запрашэнне прынята | Анлайн | Апошняя сінхранізацыя |" + "translation": "| Бяспечнае злучэнне | URL сайта | Толькі для чытання | Запрашэнне прынята | У сетцы | Апошняя сінхранізацыя |" }, { "id": "api.command_share.remote_not_valid", - "translation": "Для адмены запрашэння павінна быць указана сапраўднае бяспечнае злучэнне." + "translation": "Неабходна ўказаць сапраўдны ідэнтыфікатар бяспечнага злучэння для адмены запрашэння" }, { "id": "api.command_share.remote_id_invalid.error", - "translation": "Ідэнтыфікатар бяспечнага злучэння некарэктны: {{.Error}}" + "translation": "Ідэнтыфікатар бяспечнага злучэння несапраўдны: {{.Error}}" }, { "id": "api.command_share.remote_id.help", - "translation": "ID існуючага бяспечнага злучэння. Глядзіце Каманду `secure-connection`, каб дадаць бяспечнае злучэнне." + "translation": "Ідэнтыфікатар існуючага бяспечнага злучэння. Глядзіце каманду `secure-connection`, каб дадаць бяспечнае злучэнне." }, { "id": "api.command_share.remote_already_invited", @@ -7905,67 +7909,67 @@ }, { "id": "api.command_share.permission_required", - "translation": "Для кіравання агульнымі каналамі патрабуецца дазвол `{{.Permission}}`." + "translation": "Вам патрэбен дазвол `{{.Permission}}` для кіравання агульнымі каналамі." }, { "id": "api.command_share.not_shared_channel_unshare", - "translation": "Нельга адмяніць уласцівасць \"сумесны\" для канала, які не з'яўляецца сумесным." + "translation": "Немагчыма адмяніць агульны доступ да канала, які не з'яўляецца агульным." }, { "id": "api.command_share.no_remote_invited", - "translation": "Ніякія бяспечныя злучэнні ў гэты канал не запрашаліся." + "translation": "Да гэтага канала не запрашалася ніводнае бяспечнае злучэнне." }, { "id": "api.command_share.must_specify_valid_remote", - "translation": "Павінен быць указаны сапраўдны ідэнтыфікатар бяспечнага злучэння для запрашэння." + "translation": "Неабходна ўказаць сапраўдны ідэнтыфікатар бяспечнага злучэння для запрашэння." }, { "id": "api.command_share.missing_action", - "translation": "Прапушчана дзеянне. Даступныя дзеянні: : {{.Actions}}" + "translation": "Адсутнічае дзеянне. Даступныя дзеянні: {{.Actions}}" }, { "id": "api.command_share.invite_remote.help", - "translation": "Запрашае вонкавы асобнік Mattermost ў бягучы сумесны канал" + "translation": "Запрашае знешні экзэмпляр Mattermost у бягучы агульны канал" }, { "id": "api.command_share.invitation_sent", - "translation": "Запрашэнне сумеснага канала адпраўлена `{{.Name}} {{.SiteURL}}`." + "translation": "Запрашэнне на агульны канал адпраўлена `{{.Name}} {{.SiteURL}}`." }, { "id": "api.command_share.invalid_value.error", - "translation": "Некарэктнае значэнне '{{.Arg}}': {{.Error}}" + "translation": "Некарэктнае значэнне для '{{.Arg}}': {{.Error}}" }, { "id": "api.command_share.fetch_remote_status.error", - "translation": "Не атрымалася атрымаць статут для бяспечных злучэнняў: {{.Error}}." + "translation": "Немагчыма атрымаць стан для бяспечных злучэнняў: {{.Error}}." }, { "id": "api.command_share.fetch_remote.error", - "translation": "Памылка пры атрыманні бяспечных злучэнняў: {{.Error}}" + "translation": "Памылка атрымання бяспечных злучэнняў: {{.Error}}" }, { "id": "api.command_share.desc", - "translation": "Абагульвае бягучы канал з вонкавым серверам Mattermost." + "translation": "Абменьваецца бягучым каналам з знешнім экзэмплярам Mattermost." }, { "id": "api.command_share.could_not_uninvite.error", - "translation": "Не атрымалася адмяніць запрашэнне `{{.RemoteId}}`: {{.Error}}" + "translation": "Немагчыма адмяніць запрашэнне `{{.RemoteId}}`: {{.Error}}" }, { "id": "api.command_share.check_channel_exist.error", - "translation": "Памылка пры праверцы існавання сумеснага канала: {{.Error}}" + "translation": "Памылка пры праверцы існавання агульнага канала {{.ChannelID}}: {{.Error}}" }, { "id": "api.command_share.channel_status_id", - "translation": "Статус для ідэнтыфікатара канала `{{.ChannelId}}`" + "translation": "Стан для ідэнтыфікатара канала `{{.ChannelId}}`" }, { "id": "api.command_share.channel_status.help", - "translation": "Адлюстроўвае статус для гэтага сумеснага канала" + "translation": "Адлюстроўвае стан гэтага агульнага канала" }, { "id": "api.command_share.channel_shared", - "translation": "Гэты канал зараз абагулены." + "translation": "Гэты канал цяпер агульны." }, { "id": "api.command_share.channel_remote_id_not_exists", @@ -7973,27 +7977,27 @@ }, { "id": "api.command_share.channel_invite_not_home.error", - "translation": "Немагчыма запрасіць бяспечнае злучэнне на агульны канал, створаны недзе ў іншым месцы." + "translation": "Немагчыма запрасіць бяспечнае злучэнне ў агульны канал, створаны ў іншым месцы." }, { "id": "api.command_share.channel_invite.error", - "translation": "Памылка запрашэння `{{.Name}}` у гэты канал: {{.Error}}" + "translation": "Памылка пры запрашэнні `{{.Name}}` у гэты канал: {{.Error}}" }, { "id": "api.command_settings.unsupported.app_error", - "translation": "Налады праз каманду не даступныя на прыладзе." + "translation": "Каманда налад не падтрымліваецца на вашай прыладзе." }, { "id": "api.command_settings.desc", - "translation": "Адкрыць дыялог настроек" + "translation": "Адкрыць дыялогавае акно налад" }, { "id": "api.command_search.unsupported.app_error", - "translation": "Пошук недаступны на вашым прыладзе." + "translation": "Каманда пошуку не падтрымліваецца на вашай прыладзе." }, { "id": "api.command_search.desc", - "translation": "Шукаць тэкст у паведамленнях" + "translation": "Пошук тэксту ў паведамленнях" }, { "id": "api.command_remove.user_not_in_channel", @@ -8001,23 +8005,23 @@ }, { "id": "api.command_remove.permission.app_error", - "translation": "У Вас няма правоў для выдалення ўдзельніка." + "translation": "Вы не маеце адпаведных дазволаў для выдалення ўдзельніка." }, { "id": "api.command_remove.missing.app_error", - "translation": "Мы не змаглі знайсці карыстальніка. Верагодна, ён заблакаваны Сістэмным адміністратарам." + "translation": "Мы не змаглі знайсці карыстальніка. Магчыма, ён быў дэактываваны сістэмным адміністратарам." }, { "id": "api.command_remove.message.app_error", - "translation": "Паведамленне павінна быць прадстаўлена камандай /remove ці /kick." + "translation": "Паведамленне павінна быць прадастаўлена з камандай /remove або /kick." }, { "id": "api.command_remove.group_constrained_user_denied", - "translation": "Карыстальнік не можа быць выдалены вамі з канала, паколькі ён з'яўляецца ўдзельнікам груп, злучаных з гэтым каналам. Каб выдаліць яго з гэтага канала, ён павінен быць выдалены з злучаных груп." + "translation": "Вы не можаце выдаліць карыстальніка з канала, бо ён з'яўляецца членам груп, звязаных з гэтым каналам. Каб выдаліць яго з гэтага канала, яго трэба выдаліць з звязаных груп." }, { "id": "api.command_remove.direct_group.app_error", - "translation": "Вы не можаце выдаліць каго-небудзь з канала асабістых паведамленняў." + "translation": "Вы не можаце выдаліць каго-небудзь з канала прыватных паведамленняў." }, { "id": "api.command_remove.desc", @@ -8025,7 +8029,7 @@ }, { "id": "api.command_remote.status.help", - "translation": "Адлюстроўвае статус для ўсіх бяспечных злучэнняў" + "translation": "Адлюстроўвае стан усіх бяспечных злучэнняў" }, { "id": "api.command_remote.site_url_not_set", @@ -8033,19 +8037,19 @@ }, { "id": "api.command_remote.service_not_enabled", - "translation": "Служба бяспечнага злучэння не ўключана." + "translation": "Сэрвіс бяспечных злучэнняў не ўключаны." }, { "id": "api.command_remote.service_disabled", - "translation": "Служба бяспечнага злучэння адключана." + "translation": "Сэрвіс бяспечных злучэнняў адключаны." }, { "id": "api.command_remote.remove_remote_id.help", - "translation": "Ідэнтыфікатар бяспечнага злучэння, якое выдаляецца." + "translation": "Ідэнтыфікатар бяспечнага злучэння для выдалення." }, { "id": "api.command_remote.remove_remote.error", - "translation": "Не атрымалася выдаліць бяспечнае злучэнне: {{.Error}}" + "translation": "Немагчыма выдаліць бяспечнае злучэнне: {{.Error}}" }, { "id": "api.command_remote.remove.help", @@ -8053,11 +8057,11 @@ }, { "id": "api.command_remote.remotes_not_found", - "translation": "Бяспечных падлучэнняў не знойдзена." + "translation": "Бяспечныя злучэнні не знойдзены." }, { "id": "api.command_remote.remote_table_header", - "translation": "| Бяспечнае злучэнне | Адлюстроўванае імя | Ідэнтыфікатар падключэння | URL сайта | Запрашэнне прынята | Анлайн | Апошні пінг |" + "translation": "| Бяспечнае злучэнне | Імя для адлюстравання | Ідэнтыфікатар злучэння | URL сайта | Каманда па змаўчанні | Запрашэнне прынята | У сетцы | Апошні пінг | Выдалена |" }, { "id": "api.command_remote.remote_add_remove.help", @@ -8065,15 +8069,15 @@ }, { "id": "api.command_remote.permission_required", - "translation": "Для кіравання бяспечнымі злучэннямі патрабуецца дазвол `{{.Permission}}`." + "translation": "Вам патрэбен дазвол `{{.Permission}}` для кіравання бяспечнымі злучэннямі." }, { "id": "api.command_remote.name.hint", - "translation": "Унікальная назва для бяспечнага злучэння" + "translation": "Унікальнае імя для бяспечнага злучэння" }, { "id": "api.command_remote.missing_empty", - "translation": "Адсутнічае ці пусты `{{.Arg}}`" + "translation": "Адсутнічае або пусты `{{.Arg}}`" }, { "id": "api.command_remote.missing_command", @@ -8081,47 +8085,47 @@ }, { "id": "api.command_remote.invite_summary", - "translation": "Адпраўце наступнае зашыфраванае AES256-bit запрашэнне адміністратару знешняга сервера Mattermost разам з паролем. Для прыняцця запрашэння яны будуць выкарыстоўваць слэш-каманду `{{.Command}}`.\n\n```\n{{.Invitation}}\n```\n\n**Прадастаўце знешняму сайту шыфраванае злучэнне да вашага кластара праз** {{.SiteURL}}" + "translation": "Дашліце наступнае запрашэнне, зашыфраванае з дапамогай AES 256-біт, адміністратору знешняй сістэмы Mattermost разам з паролем. Для прыняцця запрашэння ён выкарыстае каманду `{{.Command}}`.\n\n```\n{{.Invitation}}\n```\n\n**Пераканайцеся, што бяспечнае злучэнне можа атрымаць доступ да вашай інстанцыі Mattermost праз** {{.SiteURL}}" }, { "id": "api.command_remote.invite_password.hint", - "translation": "Пароль, які выкарыстоўваецца для шыфравання запрашэння" + "translation": "Пароль для шыфравання запрашэння" }, { "id": "api.command_remote.invite.help", - "translation": "Стварыць бяспечнае злучэнне" + "translation": "Запрасіць бяспечнае злучэнне" }, { "id": "api.command_remote.invitation.hint", - "translation": "Зашыфраванае запрашэнне ад абароненага злучэння" + "translation": "Зашыфраванае запрашэнне ад бяспечнага злучэння" }, { "id": "api.command_remote.invitation.help", - "translation": "Запрашэнне ад абароненага злучэння" + "translation": "Запрашэнне ад бяспечнага злучэння" }, { "id": "api.command_remote.fetch_status.error", - "translation": "Не атрымалася атрымаць бяспечныя злучэнні: {{.Error}}" + "translation": "Немагчыма атрымаць бяспечныя злучэнні: {{.Error}}" }, { "id": "api.command_remote.encrypt_invitation.error", - "translation": "Не атрымалася зашыфраваць запрашэнне: {{.Error}}" + "translation": "Немагчыма зашыфраваць запрашэнне: {{.Error}}" }, { "id": "api.command_remote.displayname.hint", - "translation": "Як адлюстроўваецца назва бяспечнага злучэння" + "translation": "Імя для адлюстравання бяспечнага злучэння" }, { "id": "api.command_remote.displayname.help", - "translation": "Як адлюстроўваецца назва бяспечнага злучэння" + "translation": "Імя для адлюстравання бяспечнага злучэння" }, { "id": "api.command_remote.desc", - "translation": "Запрасіць бяспечныя злучэнні для сувязі паміж асобнікамі Mattermost." + "translation": "Стварыце бяспечныя злучэнні для сувязі паміж экзэмплярамі Mattermost." }, { "id": "api.command_remote.decode_invitation.error", - "translation": "Не атрымалася дэкадаваць запрашэнне: {{.Error}}" + "translation": "Немагчыма расшыфраваць запрашэнне: {{.Error}}" }, { "id": "api.command_remote.cluster_removed", @@ -8129,11 +8133,11 @@ }, { "id": "api.command_remote.add_remote.error", - "translation": "Не атрымалася дадаць бяспечнае злучэнне: {{.Error}}" + "translation": "Немагчыма дадаць бяспечнае злучэнне: {{.Error}}" }, { "id": "api.command_remote.accept_invitation.error", - "translation": "Не атрымалася прыняць запрашэнне: {{.Error}}" + "translation": "Немагчыма прыняць запрашэнне: {{.Error}}" }, { "id": "api.command_remote.accept_invitation", @@ -8141,7 +8145,7 @@ }, { "id": "api.command_remote.accept.help", - "translation": "Прыняць запрашэнне ад знешняга сервера Mattermost" + "translation": "Прыняць запрашэнне ад знешняга экзэмпляра Mattermost" }, { "id": "api.command_online.success", @@ -8149,7 +8153,7 @@ }, { "id": "api.command_online.desc", - "translation": "Устанавіць стан \"У сетцы\"" + "translation": "Усталяваць статус \"У сетцы\"" }, { "id": "api.command_offline.success", @@ -8157,119 +8161,119 @@ }, { "id": "api.command_offline.desc", - "translation": "Устанавіць стан \"Не ў сетцы\"" + "translation": "Усталяваць статус \"Не ў сетцы\"" }, { "id": "api.command_mute.success_unmute_direct_msg", - "translation": "Паведамленні ў гэтым канале больш не адключаны." + "translation": "Апавяшчэнні ў гэтым канале ўключаны." }, { "id": "api.command_mute.success_unmute", - "translation": "Апавяшчэнні ў {{.Channel}} ўключаны." + "translation": "Апавяшчэнні ў {{.Channel}} уключаны." }, { "id": "api.command_mute.success_mute_direct_msg", - "translation": "Вы не будзеце атрымліваць апавяшчэнні для гэтага канала да таго часу, пакуль адключаны гук." + "translation": "Вы не будзеце атрымліваць апавяшчэнні для гэтага канала, пакуль адключаны гук." }, { "id": "api.command_mute.success_mute", - "translation": "Вы не будзеце атрымліваць апавяшчэнні для {{.Channel}} да таго часу, пакуль адключаны апавяшчэнні." + "translation": "Вы не будзеце атрымліваць апавяшчэнні для {{.Channel}}, пакуль адключаны гук канала." }, { "id": "api.command_mute.not_member.error", - "translation": "Немагчыма адключыць канал {{.Channel}}, таму што вы не з'яўляецеся яго ўдзельнікам." + "translation": "Немагчыма адключыць гук у канале {{.Channel}}, бо вы не з'яўляецеся яго ўдзельнікам." }, { "id": "api.command_mute.no_channel.error", - "translation": "Не ўдалося знайсці ўказаны канал. Калі ласка, выкарыстоўвайце [дэскрыптар канала](https://docs.mattermost.com/messaging/managing-channels.html#naming-a-channel) для ідэнтыфікацыі каналаў." + "translation": "Немагчыма знайсці ўказаны канал. Калі ласка, выкарыстоўвайце [ідэнтыфікатар канала](https://docs.mattermost.com/messaging/managing-channels.html#naming-a-channel), каб ідэнтыфікаваць каналы." }, { "id": "api.command_mute.error", - "translation": "Не ўдалося знайсці канал {{.Channel}}. Для ідэнтыфікацыі каналаў выкарыстоўвайце [дэскрыптар канала](https://docs.mattermost.com/channels/channel-naming-conventions.html)." + "translation": "Канал {{.Channel}} не знойдзены. Калі ласка, выкарыстоўвайце [ідэнтыфікатар канала](https://docs.mattermost.com/messaging/managing-channels.html#naming-a-channel), каб ідэнтыфікаваць каналы." }, { "id": "api.command_mute.desc", - "translation": "Адключыць апавяшчэння на працоўным стале, электроннай пошце і push-паведамлення для бягучага канала або названага [канала]." + "translation": "Адключыць апавяшчэнні на працоўным стале, электроннай пошце і push-апавяшчэнні для бягучага канала або ўказанага [канала]." }, { "id": "api.command_msg.permission.app_error", - "translation": "У вас няма правоў для адпраўкі асабістага паведамлення гэтаму карыстачу." + "translation": "Вы не маеце дазволаў для адпраўкі асабістых паведамленняў гэтаму карыстальніку." }, { "id": "api.command_msg.missing.app_error", - "translation": "Карыстальнік не знойдзены." + "translation": "Немагчыма знайсці карыстальніка." }, { "id": "api.command_msg.fail.app_error", - "translation": "Адбылася памылка пры перадачы паведамлення карыстачу." + "translation": "Узнікла памылка падчас адпраўкі паведамлення карыстальніку." }, { "id": "api.command_msg.dm_fail.app_error", - "translation": "Пры стварэнні канала асабістых паведамленняў здарылася памылка." + "translation": "Узнікла памылка пры стварэнні асабістага паведамлення." }, { "id": "api.command_msg.desc", - "translation": "Адаслаць асабістае паведамленне карыстальніку" + "translation": "Даслаць асабістае паведамленне карыстальніку" }, { "id": "api.command_leave.fail.app_error", - "translation": "Падчас пакідання канала адбылася памылка." + "translation": "Узнікла памылка падчас выхаду з канала." }, { "id": "api.command_leave.desc", - "translation": "Пакінуць бягучы канал" + "translation": "Пакиньце бягучы канал" }, { "id": "api.command_join.missing.app_error", - "translation": "Не ўдалося знайсці канал." + "translation": "Не атрымалася знайсці канал." }, { "id": "api.command_join.list.app_error", - "translation": "Пры пералічэнні каналаў адбылася памылка." + "translation": "Адбылася памылка пры пераліку каналаў." }, { "id": "api.license.request_trial_license.embargoed", - "translation": "Мы не змаглі апрацаваць запыт з-за абмежаванняў для краін, на якія распаўсюджваецца эмбарга. [Даведацца больш у нашай дакументацыі](https://mattermost.com/pl/limitations-for-embargoed-countries) або звяртайцеся па адрасе legal@mattermost.com па пытаннях адносна абмежаванняў на экспарт." + "translation": "Мы не змаглі апрацаваць запыт з-за абмежаванняў для краін, на якія накладзена эбарга. [Даведайцеся больш у нашай дакументацыі](https://mattermost.com/pl/limitations-for-embargoed-countries), або звярніцеся ў legal@mattermost.com з пытаннямі аб абмежаваннях экспарту." }, { "id": "api.getThreadsForUser.bad_only_params", - "translation": "Параметры OnlyThreads і OnlyTotals ўзаемавыключальныя з getThreadsForUser" + "translation": "Параметры OnlyThreads і OnlyTotals для getThreadsForUser узаемавыключальныя" }, { "id": "api.file.test_connection_s3_settings_nil.app_error", - "translation": "Налады захоўвання файлаў маюць неўсталяваныя значэнні." + "translation": "Налады сховішча файлаў маюць не зададзеныя значэнні." }, { "id": "api.file.test_connection_email_settings_nil.app_error", - "translation": "Налады электроннай пошты маюць неўсталяваныя значэнні." + "translation": "Налады электроннай пошты маюць не зададзеныя значэнні." }, { "id": "model.channel.is_valid.1_or_more.app_error", - "translation": "Імя павінна складацца з 1 або больш малога літару і лічбаў." + "translation": "Імя павінна складацца з 1 або больш малога літарна-лічбавага сімвала." }, { "id": "model.member.is_valid.emails.app_error", - "translation": "Спіс электроннай пошты пусты" + "translation": "Спіс адрасоў электроннай пошты пусты" }, { "id": "model.member.is_valid.channel.app_error", - "translation": "Назва канала несапраўдная" + "translation": "Назва канала не з'яўляецца сапраўднай" }, { "id": "api.templates.invite_team_and_channels_subject", - "translation": "[{{ .SiteName }}] {{ .SenderName }} запрашае вас далучыцца да каналаў {{ .ChannelsLen }} у камандзе {{ .TeamDisplayName }}" + "translation": "[{{ .SiteName }}] {{ .SenderName }} запрасіў вас далучыцца да {{ .ChannelsLen }} каналаў у камандзе {{ .TeamDisplayName }}" }, { "id": "api.templates.invite_team_and_channels_body.title", - "translation": "{{ .SenderName }} запрасіў вас далучыцца да каналаў {{ .ChannelsLen }} у камандзе {{ .TeamDisplayName }}" + "translation": "{{ .SenderName }} запрасіў вас далучыцца да {{ .ChannelsLen }} каналаў у камандзе {{ .TeamDisplayName }}" }, { "id": "api.templates.invite_team_and_channel_subject", - "translation": "[{{ .SiteName }}] {{ .SenderName }} запрашае вас далучыцца да {{ .ChannelName }} у камандзе {{ .TeamDisplayName }}" + "translation": "[{{ .SiteName }}] {{ .SenderName }} запрасіў вас далучыцца да канала {{ .ChannelName }} у камандзе {{ .TeamDisplayName }}" }, { "id": "api.templates.invite_team_and_channel_body.title", - "translation": "{{ .SenderName }} запрасіў вас далучыцца да {{ .ChannelName }} у камандзе {{ .TeamDisplayName }}" + "translation": "{{ .SenderName }} запрасіў вас далучыцца да канала {{ .ChannelName }} у камандзе {{ .TeamDisplayName }}" }, { "id": "api.team.invite_members_to_team_and_channels.invalid_body.app_error", @@ -8281,6 +8285,3410 @@ }, { "id": "app.user.update_thread_read_for_user_by_post.app_error", - "translation": "Недапушчальны post_id" + "translation": "Няправільны post_id." + }, + { + "id": "api.acknowledgement.delete.archived_channel.app_error", + "translation": "Нельга выдаліць пацвярджэнне ў архіваваным канале." + }, + { + "id": "api.acknowledgement.delete.deadline.app_error", + "translation": "Нельга выдаліць пацвярджэнне пасля таго, як мінула 5 хвілін." + }, + { + "id": "api.acknowledgement.save.archived_channel.app_error", + "translation": "Нельга захаваць пацвярджэнне ў архіваваным канале." + }, + { + "id": "api.admin.syncables_error", + "translation": "Не атрымалася дадаць карыстальніка ў group-teams і group-channels" + }, + { + "id": "api.channel.gm_to_channel_conversion.not_allowed_for_user.request_error", + "translation": "Карыстальніку забаронена канвертацыя групавога паведамлення ў прыватны канал" + }, + { + "id": "api.channel.group_message.converted.to_private_channel", + "translation": "{{.ConvertedByUsername}} стварыў гэты канал з групавога паведамлення з {{.GMMembers}}." + }, + { + "id": "api.channel.patch_update_channel.update_direct_or_group_messages_not_allowed.app_error", + "translation": "Вам забаронена змена назвы, display_name і прызначэння прамых або групавых паведамленняў." + }, + { + "id": "api.channel.update_channel.update_direct_or_group_messages_not_allowed.app_error", + "translation": "Вам забаронена змена назвы, display_name і прызначэння прамых або групавых паведамленняў." + }, + { + "id": "api.cloud.notify_admin_to_upgrade_error.already_notified", + "translation": "Адміністратар ужо апавешчаны" + }, + { + "id": "api.cloud.teams_limit_reached.create", + "translation": "Немагчыма стварыць каманду, бо дасягнуты ліміт каманд" + }, + { + "id": "api.cloud.teams_limit_reached.restore", + "translation": "Немагчыма аднавіць каманду, бо дасягнуты ліміт каманд" + }, + { + "id": "api.command_exportlink.hint", + "translation": "[job-id|zip імя файла|{{.LatestMsg}}]" + }, + { + "id": "api.command_exportlink.invalid.app_error", + "translation": "Немагчыма знайсці запытаны файл." + }, + { + "id": "api.command_exportlink.link.text", + "translation": "Вы можаце спампаваць свой файл тут: {{.Link}}.\nГэтая спасылка будзе несапраўднай праз {{.Expiration}}." + }, + { + "id": "api.command_exportlink.list.app_error", + "translation": "Немагчыма атрымаць спіс экспарту." + }, + { + "id": "api.command_exportlink.name", + "translation": "exportlink" + }, + { + "id": "api.command_exportlink.permission.app_error", + "translation": "У вас недастаткова правоў для выканання гэтай каманды." + }, + { + "id": "api.command_exportlink.presign.app_error", + "translation": "Немагчыма стварыць URL папярэдняга падпісання." + }, + { + "id": "api.command_help.success", + "translation": "Mattermost - гэта платформа з адкрытым зыходным кодам для бяспечнай камунікацыі, супрацоўніцтва і арганізацыі працы паміж інструментамі і камандамі.\nMattermost змяшчае два асноўныя інструменты:\n\n**Каналы** - Заставайцеся на сувязі са сваёй камандай праз 1:1 і групавыя паведамленні.\n**[Плэйбукі](/playbooks)** - Стварайце і наладжвайце паўтаральныя працэсы для дасягнення канкрэтных і прадказальных вынікаў.\n\n[Праглядзець дакументацыю і кіраўніцтва]({{.HelpLink}})" + }, + { + "id": "api.command_invite.channel_constrained_user_denied", + "translation": "Гэты канал кіруецца групамі. Гэты карыстальнік не з'яўляецца часткай групы, якая сінхранізуецца з гэтым каналам." + }, + { + "id": "api.command_invite.successOverflow", + "translation": "{{.FirstUser}} і {{.Others}} дададзены ў канал {{.Channel}}." + }, + { + "id": "api.command_invite.user_already_in_channel.overflow", + "translation": "{{.FirstUser}} і {{.Others}} ужо знаходзяцца ў канале." + }, + { + "id": "api.command_invite.user_not_in_team.messageOverflow", + "translation": "Вы можаце дадаць {{.FirstUser}} і {{.Others}} у гэты канал, калі яны стануць членамі каманды **{{.Team}}**." + }, + { + "id": "api.command_marketplace.desc", + "translation": "Адкрыйце Маркетплейс" + }, + { + "id": "api.command_marketplace.name", + "translation": "крама плагінаў" + }, + { + "id": "api.command_marketplace.unsupported.app_error", + "translation": "Каманда marketplace не падтрымліваецца на вашай прыладзе." + }, + { + "id": "api.command_remote.confirm_invitation.error", + "translation": "Немагчыма пацвердзіць запрашэнне: {{.Error}}" + }, + { + "id": "api.command_share.channel_not_shared.error", + "translation": "Немагчыма запрасіць аддаленага карыстальніка, бо канал {{.ChannelID}} не з'яўляецца агульным." + }, + { + "id": "api.config.update_config.translations.app_error", + "translation": "Немагчыма абнавіць пераклады сервера." + }, + { + "id": "api.context.ip_filtering.apply_ip_filters.app_error", + "translation": "Узнікла памылка пры прымяненні IP-фільтраў" + }, + { + "id": "api.context.ip_filtering.get_ip_filters.app_error", + "translation": "Узнікла памылка пры атрыманні IP-фільтраў" + }, + { + "id": "api.context.ip_filtering.get_my_ip.failed", + "translation": "Узнікла памылка пры атрыманні IP-адрасу кліента" + }, + { + "id": "api.context.ip_filtering.not_available.app_error", + "translation": "IP-фільтрацыя недаступная на гэтым серверы" + }, + { + "id": "api.context.outgoing_oauth_connection.create_connection.app_error", + "translation": "Узнікла памылка пры стварэнні выходнага OAuth-злучэння." + }, + { + "id": "api.context.outgoing_oauth_connection.create_connection.input_error", + "translation": "Некарэктныя ўваходныя параметры." + }, + { + "id": "api.context.outgoing_oauth_connection.delete_connection.app_error", + "translation": "Узнікла памылка пры выдаленні выходнага OAuth-злучэння." + }, + { + "id": "api.context.outgoing_oauth_connection.list_connections.app_error", + "translation": "Узнікла памылка пры пераліку выходных OAuth-злучэнняў." + }, + { + "id": "api.context.outgoing_oauth_connection.list_connections.input_error", + "translation": "Некарэктныя ўваходныя параметры." + }, + { + "id": "api.context.outgoing_oauth_connection.not_available.configuration_disabled", + "translation": "Выходныя OAuth-злучэнні недаступныя на гэтым серверы." + }, + { + "id": "api.context.outgoing_oauth_connection.update_connection.app_error", + "translation": "Узнікла памылка пры абнаўленні выходнага OAuth-злучэння." + }, + { + "id": "api.context.outgoing_oauth_connection.update_connection.input_error", + "translation": "Некарэктныя ўваходныя параметры." + }, + { + "id": "api.context.outgoing_oauth_connection.validate_connection_credentials.app_error", + "translation": "Узнікла памылка пры праверцы даных для ўваходу выходнага OAuth-злучэння." + }, + { + "id": "api.context.outgoing_oauth_connection.validate_connection_credentials.input_error", + "translation": "Немагчыма атрымаць даныя для ўваходу з указанай канфігурацыяй злучэння." + }, + { + "id": "api.custom_status.set_custom_statuses.emoji_not_found", + "translation": "Немагчыма абнавіць карыстальніцкі статус. Эмодзі з такой назвай не існуе." + }, + { + "id": "api.draft.create_draft.can_not_draft_to_deleted.error", + "translation": "Немагчыма захаваць чарнавік у выдаленым канале" + }, + { + "id": "api.drafts.disabled.app_error", + "translation": "Функцыя чарнавікоў адключана." + }, + { + "id": "api.emoji.get_multiple_by_name_too_many.request_error", + "translation": "Немагчыма атрымаць столькі эмодзі па імені. Адначасова можна запытаць толькі {{.MaxNames}} эмодзі." + }, + { + "id": "api.emoji.upload.seek.app_error", + "translation": "Немагчыма перайсці да пачатку файла." + }, + { + "id": "api.error_no_organization_name_provided_for_self_hosted_onboarding", + "translation": "Памылка: не названа арганізацыя для самастойнай налады." + }, + { + "id": "api.file.cloud_upload.app_error", + "translation": "Загрузка праз mmctl у Cloud-экземпляр не падтрымліваецца. Калі ласка, азнаёмцеся з дакументацыяй тут: https://docs.mattermost.com/manage/cloud-data-export.html." + }, + { + "id": "api.getUsersForReporting.invalid_active_filter", + "translation": "Немагчыма схаваць як актыўных, так і неактыўных карыстальнікаў." + }, + { + "id": "api.getUsersForReporting.invalid_page_size", + "translation": "Памер старонкі несапраўдны або занадта вялікі." + }, + { + "id": "api.getUsersForReporting.invalid_team_filter", + "translation": "Пададзены несапраўдны ідэнтыфікатар каманды." + }, + { + "id": "api.license.request-trial.bad-request.business-email", + "translation": "Несапраўдны рабочы электронны адрас для пробнай версіі." + }, + { + "id": "api.payload.parse.error", + "translation": "Памылка пры разборы карыснай нагрузкі." + }, + { + "id": "api.post.move_thread.disabled.app_error", + "translation": "Перамяшчэнне гілак адключана" + }, + { + "id": "api.post.move_thread.no_permission", + "translation": "У вас няма дазволу на перамяшчэнне гэтай гілкі." + }, + { + "id": "api.post.post_priority.max_recipients_persistent_notification_post.request_error", + "translation": "Паведамленні пастаянных апавяшчэнняў дапускаюць максімум {{.MaxRecipients}} атрымальнікаў." + }, + { + "id": "api.post.post_priority.min_recipients_persistent_notification_post.request_error", + "translation": "Паведамленні пастаянных апавяшчэнняў павінны мець мінімум 1 атрымальніка." + }, + { + "id": "api.post.post_priority.persistent_notification_validation_error.request_error", + "translation": "Валідацыя пастаяннага апавяшчэння не ўдалася." + }, + { + "id": "api.post.post_priority.priority_post_not_allowed_for_user.request_error", + "translation": "Карыстальніку не дазволена ствараць прыярытэтныя паведамленні або пастаянныя апавяшчэнні." + }, + { + "id": "api.post.post_priority.priority_post_only_allowed_for_root_post.request_error", + "translation": "Толькі карэнныя паведамленні могуць мець прыярытэт." + }, + { + "id": "api.post.post_priority.urgent_persistent_notification_post.request_error", + "translation": "Паведамленні пастаянных апавяшчэнняў павінны мець прыярытэт \"Тэрмінова\"." + }, + { + "id": "api.roles.get_multiple_by_name_too_many.request_error", + "translation": "Немагчыма атрымаць столькі роляў па імені. Адначасова можна запытаць толькі {{.MaxNames}} роляў." + }, + { + "id": "api.saml.invalid_email_token.app_error", + "translation": "Няправільны email_token" + }, + { + "id": "api.server.cws.health_check.app_error", + "translation": "Сервер CWS недаступны." + }, + { + "id": "api.server.cws.needs_enterprise_edition", + "translation": "Паслуга даступная толькі ў выданні Mattermost Enterprise" + }, + { + "id": "api.server.cws.subscribe_to_newsletter.app_error", + "translation": "Серверу CWS не ўдалося падпісацца на рассылку навін." + }, + { + "id": "api.system.logs.invalidFilter", + "translation": "Няправільны фільтр часопіса" + }, + { + "id": "api.team.invite_guests_to_channels.disabled.error", + "translation": "Гасцявыя ўліковыя запісы адключаны" + }, + { + "id": "api.team.invite_guests_to_channels.license.error", + "translation": "Ваша ліцэнзія не падтрымлівае гасцявыя ўліковыя запісы" + }, + { + "id": "api.team.update_team_member_roles.guest.app_error", + "translation": "Няправільнае абнаўленне члена каманды: госць не можа быць членам каманды або адміністратарам каманды, калі ласка, спачатку павысіце яго як карыстальніка." + }, + { + "id": "api.team.user.missing_account", + "translation": "Немагчыма знайсці карыстальніка." + }, + { + "id": "api.templates.ip_filters_changed.button", + "translation": "Праглядзець змены" + }, + { + "id": "api.templates.ip_filters_changed.subTitle", + "translation": "@{{ .InitiatingUsername }} змяніў налады IP-фільтрацыі для вашага працоўнага месца па адрасе: {{ .SiteURL }}" + }, + { + "id": "api.templates.ip_filters_changed.subject", + "translation": "Змены ў IP-фільтрах вашага працоўнага месца" + }, + { + "id": "api.templates.ip_filters_changed.title", + "translation": "Змены IP-фільтрацыі для вашага працоўнага месца" + }, + { + "id": "api.command_exportlink.desc", + "translation": "Стварыць спасылку для загрузкі экспарту." + }, + { + "id": "api.command_exportlink.driver.app_error", + "translation": "Драйвер сховішча файлаў не падтрымлівае стварэнне спасылак." + }, + { + "id": "api.command_exportlink.empty.app_error", + "translation": "Файл экспарту не знойдзены." + }, + { + "id": "api.server.hosted_signup_unavailable.error", + "translation": "Партал недаступны для самастойнай рэгістрацыі." + }, + { + "id": "api.templates.ip_filters_changed_footer.contact_support", + "translation": "Звязацца з падтрымкай" + }, + { + "id": "api.templates.ip_filters_changed_footer.log_in_to_customer_portal", + "translation": "Увайдзіце ў партал кліента, каб скінуць IP-фільтрацыю" + }, + { + "id": "api.templates.ip_filters_changed_footer.send_an_email_to", + "translation": "Адправіць ліст на {{ .InitiatingUserEmail }}" + }, + { + "id": "api.templates.ip_filters_changed_footer.title", + "translation": "Узніклі праблемы з доступам да вашага працоўнага месца?" + }, + { + "id": "api.templates.license_up_for_renewal_contact_sales", + "translation": "Кантакты аддзела продажаў" + }, + { + "id": "api.upload.create.upload_too_large.app_error", + "translation": "Немагчыма загрузіць файл. Файл занадта вялікі." + }, + { + "id": "api.user.add_user_to_group_syncables.not_ldap_user.app_error", + "translation": "не з'яўляецца карыстальнікам ldap" + }, + { + "id": "api.user.get_profile_image_path.app_error", + "translation": "Памылка пры праверцы наяўнасці ў карыстальніка ўласнага малюнка профілю." + }, + { + "id": "api.user.get_users.validation.app_error", + "translation": "Памылка атрымання роляў падчас праверкі." + }, + { + "id": "api.user.invalidate_password_recovery_tokens.error", + "translation": "Немагчыма атрымаць токены па тыпе пры ануляванні токенаў аднаўлення пароля" + }, + { + "id": "api.user.invalidate_password_recovery_tokens_delete.error", + "translation": "Немагчыма выдаліць токен пры ануляванні токенаў аднаўлення пароля" + }, + { + "id": "api.user.invalidate_password_recovery_tokens_parse.error", + "translation": "Немагчыма разабраць токен пры ануляванні токенаў аднаўлення пароля" + }, + { + "id": "api.user.login.remote_users.login.error", + "translation": "Уваход не атрымаўся, бо аддаленым карыстальнікам не дазволена ўваходзіць у сістэму." + }, + { + "id": "app.acknowledgement.delete.app_error", + "translation": "Немагчыма выдаліць пацвярджэнне." + }, + { + "id": "app.acknowledgement.get.app_error", + "translation": "Немагчыма атрымаць пацвярджэнне." + }, + { + "id": "app.acknowledgement.getforpost.get.app_error", + "translation": "Немагчыма атрымаць пацвярджэнне для паведамлення." + }, + { + "id": "app.acknowledgement.save.save.app_error", + "translation": "Немагчыма захаваць пацвярджэнне для паведамлення." + }, + { + "id": "app.channel.add_member.deleted_user.app_error", + "translation": "Немагчыма дадаць карыстальніка як удзельніка канала." + }, + { + "id": "app.channel.count_urgent_posts_since.app_error", + "translation": "Немагчыма падлічыць тэрміновыя паведамленні з указанай даты." + }, + { + "id": "app.channel.get_channels_member_count.existing.app_error", + "translation": "Немагчыма знайсці колькасць удзельнікаў для дадзеных каналаў." + }, + { + "id": "app.channel.get_channels_member_count.find.app_error", + "translation": "Немагчыма знайсці колькасць удзельнікаў." + }, + { + "id": "app.channel.get_channels_with_unreads_and_with_mentions.app_error", + "translation": "Немагчыма праверыць непрачытаныя паведамленні і згадкі" + }, + { + "id": "app.channel.get_common_teams.incorrect_channel_type", + "translation": "Канал не з'яўляецца групавым паведамленнем." + }, + { + "id": "app.channel.get_common_teams.store_get_common_teams_error", + "translation": "Не атрымалася сабраць агульныя каманды." + }, + { + "id": "app.channel.get_priority_for_posts.app_error", + "translation": "Немагчыма атрымаць прыярытэт для паведамленняў" + }, + { + "id": "app.channel.gm_conversion_set_categories.delete_all.error", + "translation": "Немагчыма выдаліць існуючыя катэгорыі бакавой панэлі для ператворанага GM." + }, + { + "id": "app.channel.group_message_conversion.channel_member_missing", + "translation": "Немагчыма знайсці членства карыстальніка ў канале" + }, + { + "id": "app.channel.group_message_conversion.incorrect_team", + "translation": "Указаны ў запыце на канвертацыю ідэнтыфікатар каманды не ўтрымлівае ўсіх удзельнікаў групавога паведамлення" + }, + { + "id": "app.channel.group_message_conversion.original_channel_not_gm", + "translation": "Канал, які канвертуецца, не з'яўляецца групавым паведамленнем. Вы можаце канвертаваць толькі групавыя паведамленні" + }, + { + "id": "app.channel.group_message_conversion.post_message.error", + "translation": "Немагчыма стварыць паведамленне аб канвертацыі групавога паведамлення ў канал" + }, + { + "id": "app.channel.patch_channel_members_notify_props.app_error", + "translation": "Немагчыма абнавіць параметры апавяшчэнняў удзельнікаў канала." + }, + { + "id": "app.channel.patch_channel_members_notify_props.too_many", + "translation": "Немагчыма абнавіць столькі ўдзельнікаў канала. Адначасова можна абнавіць толькі {{.Max}} удзельнікаў канала." + }, + { + "id": "app.channel.update_member.app_error", + "translation": "Немагчыма абнавіць удзельніка канала." + }, + { + "id": "app.channel.update_member.notify_props_limit_exceeded.app_error", + "translation": "Немагчыма абнавіць удзельніка канала, перавышаны ліміт памеру параметраў апавяшчэнняў." + }, + { + "id": "app.cloud.trial_plan_bot_message", + "translation": "{{.UsersNum}} удзельнікаў працоўнай прасторы {{.WorkspaceName}} запыталі пачаць пробную версію Enterprise для доступу да: " + }, + { + "id": "app.cloud.trial_plan_bot_message_single", + "translation": "{{.UsersNum}} удзельнік працоўнай прасторы {{.WorkspaceName}} запытаў пачаць пробную версію Enterprise для доступу да: " + }, + { + "id": "app.cloud.upgrade_plan_bot_message", + "translation": "{{.UsersNum}} удзельнікаў працоўнай прасторы {{.WorkspaceName}} запыталі абнаўленне працоўнай прасторы для: " + }, + { + "id": "app.import.validate_user_teams_import_data.invalid_auth_service.error", + "translation": "Няправільны сэрвіс аўтарызацыі: {{.AuthService}}" + }, + { + "id": "app.job.error", + "translation": "Памылка падчас выканання задачы." + }, + { + "id": "app.last_accessible_file.app_error", + "translation": "Памылка атрымання апошняга даступнага файла" + }, + { + "id": "app.last_accessible_post.app_error", + "translation": "Памылка атрымання апошняга даступнага паведамлення" + }, + { + "id": "app.login.doLogin.updateLastLogin.error", + "translation": "Немагчыма абнавіць часовую метку апошняга ўваходу" + }, + { + "id": "app.cloud.upgrade_plan_bot_message_single", + "translation": "{{.UsersNum}} удзельнік(ца) працоўнай прасторы {{.WorkspaceName}} запытаў(ла) абнаўленне працоўнай прасторы для: " + }, + { + "id": "app.compile_csv_chunks.header_error", + "translation": "Не атрымалася запісаць загалоўкі CSV." + }, + { + "id": "app.compile_report_chunks.unsupported_format", + "translation": "Непадтрымоўваны фармат справаздачы." + }, + { + "id": "app.desktop_token.generateServerToken.invalid_or_expired", + "translation": "Токен не існуе або яго тэрмін дзеяння скончыўся" + }, + { + "id": "app.desktop_token.validate.invalid", + "translation": "Токен несапраўдны або яго тэрмін дзеяння скончыўся" + }, + { + "id": "app.desktop_token.validate.no_user", + "translation": "Нельга знайсці карыстальніка для гэтага токена" + }, + { + "id": "app.draft.delete.app_error", + "translation": "Нельга выдаліць чарнавік." + }, + { + "id": "app.draft.feature_disabled", + "translation": "Функцыя чарнавікоў адключана." + }, + { + "id": "app.draft.get.app_error", + "translation": "Не атрымалася атрымаць чарнавік." + }, + { + "id": "app.draft.get_drafts.app_error", + "translation": "Не атрымалася атрымаць чарнавікі карыстальніка." + }, + { + "id": "app.draft.get_for_draft.app_error", + "translation": "Не атрымалася атрымаць файлы для чарнавіка." + }, + { + "id": "app.draft.save.app_error", + "translation": "Нельга захаваць чарнавік." + }, + { + "id": "app.eport.generate_presigned_url.config.app_error", + "translation": "Гэтыя дзеянні патрабуюць выкарыстання спецыялізаванага сховішча для экспарту." + }, + { + "id": "app.eport.generate_presigned_url.driver.app_error", + "translation": "Драйвер вашага сховішча для экспарту не падтрымлівае генерацыю URL-адрасоў з папярэднім подпісам." + }, + { + "id": "app.eport.generate_presigned_url.featureflag.app_error", + "translation": "Гэтая функцыя абмежавана флагам функцыі." + }, + { + "id": "app.eport.generate_presigned_url.fileexist.app_error", + "translation": "Немагчыма праверыць, ці існуе файл." + }, + { + "id": "app.eport.generate_presigned_url.link.app_error", + "translation": "Немагчыма згенэраваць URL з папярэднім подпісам." + }, + { + "id": "app.eport.generate_presigned_url.notfound.app_error", + "translation": "Файл экспарту не знойдзены." + }, + { + "id": "app.file.cloud.get.app_error", + "translation": "Немагчыма атрымаць файл, бо ён перавышае ліміт хмарнага плана." + }, + { + "id": "app.file_info.get.gif.app_error", + "translation": "Немагчыма дэкадаваць GIF." + }, + { + "id": "app.file_info.set_searchable_content.app_error", + "translation": "Немагчыма ўсталяваць змест файла для пошуку." + }, + { + "id": "app.group.username_conflict", + "translation": "Карыстальнік з імем \"{{.Username}}\" ужо існуе." + }, + { + "id": "app.import.import_channel.deleting.app_error", + "translation": "Немагчыма заархіваваць імпартаваны канал." + }, + { + "id": "app.notify_admin.save.app_error", + "translation": "Немагчыма захаваць даныя апавяшчэнняў." + }, + { + "id": "app.notify_admin.send_notification_post.app_error", + "translation": "Немагчыма адправіць паведамленне з апавяшчэннем." + }, + { + "id": "app.oauth.remove_auth_data_by_client_id.app_error", + "translation": "Немагчыма выдаліць даныя OAuth." + }, + { + "id": "app.plugin.skip_installation.app_error", + "translation": "Прапускаецца ўстаноўка плагіна {{.Id}}, паколькі існуючая версія роўная або навейшая." + }, + { + "id": "app.plugin.subpath_parse.app_error", + "translation": "Не атрымалася разабраць падшлях SiteURL" + }, + { + "id": "app.post.analytics_teams_count.app_error", + "translation": "Немагчыма атрымаць даныя аб выкарыстанні каманд" + }, + { + "id": "app.post.cloud.get.app_error", + "translation": "Немагчыма атрымаць паведамленне, бо яно перавышае ліміт плана хмарнага сэрвісу." + }, + { + "id": "app.post.delete_post.get_team.app_error", + "translation": "Памылка пры атрыманні каманды." + }, + { + "id": "app.post.move_thread.from_another_channel", + "translation": "Гэтая гілка была перамешчана з іншага канала" + }, + { + "id": "app.post.move_thread_command.channel.multiple_messages", + "translation": "Гілка з {{.NumMessages}} паведамленнямі была перанесена: {{.Link}}\n" + }, + { + "id": "app.post.move_thread_command.channel.one_message", + "translation": "Паведамленне было перанесена: {{.Link}}\n" + }, + { + "id": "app.post.move_thread_command.direct_or_group.multiple_messages", + "translation": "Гілка з {{.NumMessages}} паведамленнямі была перанесена ў асабістае/групавое паведамленне\n" + }, + { + "id": "app.post.move_thread_command.direct_or_group.one_message", + "translation": "Паведамленне было перамешчана ў Асабістае/Групавое паведамленне\n" + }, + { + "id": "app.post.move_thread_command.error", + "translation": "Немагчыма выдаліць гілку" + }, + { + "id": "app.post_persistent_notification.delete_by_channel.app_error", + "translation": "Немагчыма выдаліць пастаянныя апавяшчэнні па канале." + }, + { + "id": "app.post_persistent_notification.delete_by_team.app_error", + "translation": "Немагчыма выдаліць пастаянныя апавяшчэнні па камандзе." + }, + { + "id": "app.post_priority.delete_persistent_notification_post.app_error", + "translation": "Не атрымалася выдаліць пост пастаяннага апавяшчэння" + }, + { + "id": "app.post_prority.get_for_post.app_error", + "translation": "Немагчыма атрымаць прыярытэт паведамлення" + }, + { + "id": "app.post_reminder_dm", + "translation": "Прывітанне, вось ваша напамін пра гэтае паведамленне ад @{{.Username}}: {{.SiteURL}}/{{.TeamName}}/pl/{{.PostId}}" + }, + { + "id": "app.reaction.permanent_delete_by_user.app_error", + "translation": "Немагчыма выдаліць рэакцыі для карыстальніка." + }, + { + "id": "app.reaction.save.save.too_many_reactions", + "translation": "Дасягнуты ліміт рэакцый для гэтага паведамлення." + }, + { + "id": "app.report.date_range.all_time", + "translation": "увесь час" + }, + { + "id": "app.report.date_range.last_30_days", + "translation": "за апошнія 30 дзён" + }, + { + "id": "app.report.date_range.last_6_months", + "translation": "за апошнія 6 месяцаў" + }, + { + "id": "app.report.date_range.previous_month", + "translation": "за папярэдні месяц" + }, + { + "id": "app.report.get_user_count_for_report.store_error", + "translation": "Не атрымалася атрымаць колькасць карыстальнікаў." + }, + { + "id": "app.report.get_user_report.store_error", + "translation": "Не атрымалася атрымаць справаздачу карыстальніка." + }, + { + "id": "app.report.send_report_to_user.export_finished", + "translation": "Ваш экспарт гатовы. Файл CSV змяшчае даныя карыстальнікаў за {{.DateRange}}. Націсніце на спасылку ніжэй, каб спампаваць справаздачу." + }, + { + "id": "app.report.send_report_to_user.failed_to_save", + "translation": "Не атрымалася захаваць інфармацыю пра файл." + }, + { + "id": "app.report.send_report_to_user.missing_date_range", + "translation": "Адсутнічае дыяпазон дат" + }, + { + "id": "app.report.send_report_to_user.missing_user_id", + "translation": "Няма ідэнтыфікатара карыстальніка для адпраўкі справаздачы" + }, + { + "id": "app.report.start_users_batch_export.job_exists", + "translation": "Заданне ўжо існуе для гэтага карыстальніка і дыяпазону дат." + }, + { + "id": "app.report.start_users_batch_export.license_error", + "translation": "Пакетны экспарт справаздач даступны толькі для Pro і Enterprise." + }, + { + "id": "app.report.start_users_batch_export.started_export", + "translation": "Вы пачалі экспарт даных карыстальнікаў за {{.DateRange}}. Калі экспарт будзе завершаны, файл CSV будзе дастаўлены вам у гэтым асабістым паведамленні." + }, + { + "id": "app.save_config.plugin_hook_error", + "translation": "Памылка пры выкананні хука плагіна пры захаванні канфігурацыі." + }, + { + "id": "app.save_csv_chunk.write_error", + "translation": "Не атрымалася запісаць фрагмент CSV." + }, + { + "id": "app.save_report_chunk.unsupported_format", + "translation": "Непадтрымоўваны фармат справаздачы." + }, + { + "id": "app.team.clear_cache.app_error", + "translation": "Памылка ачысткі кэша члена каманды" + }, + { + "id": "app.teams.analytics_teams_count.app_error", + "translation": "Немагчыма атрымаць колькасць каманд" + }, + { + "id": "app.thread.mark_all_as_read_by_channels.app_error", + "translation": "Немагчыма пазначыць усе гілкі як прачытаныя па канале" + }, + { + "id": "app.upload.upload_data.gen_info.app_error", + "translation": "Немагчыма стварыць інфармацыю пра файл з загружаных даных." + }, + { + "id": "app.usage.get_storage_usage.app_error", + "translation": "Немагчыма атрымаць выкарыстанне сховішча." + }, + { + "id": "app.user.get_badge_count.app_error", + "translation": "Мы не змаглі атрымаць колькасць значкоў для карыстальніка." + }, + { + "id": "common.parse_error_int64", + "translation": "Немагчыма дэкадаваць значэнне:{{.Value}} у int64." + }, + { + "id": "ent.elasticsearch.create_client.ca_cert_missing", + "translation": "Немагчыма адкрыць CA-файл для {{.Backend}}." + }, + { + "id": "ent.elasticsearch.create_client.client_cert_malformed", + "translation": "Дэкадаванне кліенцкага сертыфіката для {{.Backend}} не ўдалося." + }, + { + "id": "ent.elasticsearch.create_client.client_cert_missing", + "translation": "Немагчыма адкрыць файл кліенцкага сертыфіката для {{.Backend}}." + }, + { + "id": "ent.elasticsearch.create_client.client_key_missing", + "translation": "Немагчыма адкрыць файл кліенцкага ключа для {{.Backend}}." + }, + { + "id": "ent.elasticsearch.max_version.app_error", + "translation": "Версія {{.Backend}} {{.Version}} вышэй за максімальна падтрымоўваную версію {{.MaxVersion}}" + }, + { + "id": "ent.outgoing_oauth_connections.authenticate.app_error", + "translation": "Адбылася памылка пры аўтэнтыфікацыі зыходнага OAuth-злучэння: {{ .Error }}" + }, + { + "id": "ent.outgoing_oauth_connections.connection_matching_audience_exists.app_error", + "translation": "Ужо існуе зыходнае OAuth-злучэнне для дадзенай аўдыторыі." + }, + { + "id": "ent.outgoing_oauth_connections.connection_matching_audience_exists.not_found", + "translation": "Няма зыходнага OAuth-злучэння для дадзенай аўдыторыі." + }, + { + "id": "ent.outgoing_oauth_connections.delete_connection.app_error", + "translation": "Адбылася памылка пры выдаленні зыходнага OAuth-злучэння." + }, + { + "id": "ent.outgoing_oauth_connections.feature_disabled", + "translation": "Зыходныя OAuth-злучэнні недаступныя на гэтым серверы." + }, + { + "id": "ent.outgoing_oauth_connections.get_connection.app_error", + "translation": "Адбылася памылка пры атрыманні зыходнага OAuth-злучэння." + }, + { + "id": "ent.outgoing_oauth_connections.get_connection.not_found.app_error", + "translation": "Зыходнае OAuth-злучэнне не знойдзена." + }, + { + "id": "ent.outgoing_oauth_connections.get_connection_for_audience.app_error", + "translation": "Адбылася памылка пры атрыманні зыходнага OAuth-злучэння для аўдыторыі." + }, + { + "id": "ent.outgoing_oauth_connections.get_connection_for_audience.not_found.app_error", + "translation": "Зыходнае OAuth-злучэнне для дадзенай аўдыторыі не знойдзена." + }, + { + "id": "ent.outgoing_oauth_connections.get_connections.app_error", + "translation": "Адбылася памылка пры атрыманні зыходных OAuth-злучэнняў." + }, + { + "id": "ent.outgoing_oauth_connections.license_disable.app_error", + "translation": "Ваша ліцэнзія не падтрымлівае зыходныя OAuth-злучэнні." + }, + { + "id": "ent.outgoing_oauth_connections.save_connection.app_error", + "translation": "Адбылася памылка пры захаванні зыходнага OAuth-злучэння: {{ .Error }}" + }, + { + "id": "ent.outgoing_oauth_connections.save_connection.audience_duplicated", + "translation": "Ужо існуе зыходнае OAuth-злучэнне для дадзенай аўдыторыі: {{ .Audience }}" + }, + { + "id": "ent.outgoing_oauth_connections.save_connection.audience_invalid", + "translation": "Дадзеная аўдыторыя несапраўдная: {{ .Error }}" + }, + { + "id": "ent.outgoing_oauth_connections.update_connection.app_error", + "translation": "Адбылася памылка пры абнаўленні зыходнага злучэння oauth: {{ .Error }}" + }, + { + "id": "ent.outgoing_oauth_connections.update_connection.audience_duplicated", + "translation": "Для паказанай аўдыторыі ўжо існуе зыходнае злучэнне oauth: {{ .Audience }}" + }, + { + "id": "ent.outgoing_oauth_connections.update_connection.audience_invalid", + "translation": "Паказаная аўдыторыя несапраўдная: {{ .Error }}" + }, + { + "id": "ent.saml.configure.certificate_parse_error.app_error", + "translation": "SAML не змог паспяхова загрузіць публічны сертыфікат пастаўшчыка ідэнтыфікацыі, калі ласка, звярніцеся да вашага сістэмнага адміністратара." + }, + { + "id": "humanize.list_join", + "translation": "{{.OtherItems}} і {{.LastItem}}" + }, + { + "id": "model.acknowledgement.is_valid.post_id.app_error", + "translation": "Несапраўдны ідэнтыфікатар паведамлення." + }, + { + "id": "model.acknowledgement.is_valid.user_id.app_error", + "translation": "Несапраўдны ідэнтыфікатар карыстальніка." + }, + { + "id": "model.channel_member.is_valid.channel_auto_follow_threads_value.app_error", + "translation": "Несапраўднае значэнне channel-auto-follow-threads." + }, + { + "id": "model.channel_member.is_valid.notify_props.app_error", + "translation": "Перавышаны ліміт памеру notify props." + }, + { + "id": "model.config.is_valid.amazons3_timeout.app_error", + "translation": "Недапушчальнае значэнне тайм-аўту {{.Value}}. Павінна быць станоўчая лічба." + }, + { + "id": "model.config.is_valid.data_retention.file_retention_both_zero.app_error", + "translation": "Колькасць дзён захоўвання файлаў і гадзін захоўвання файлаў не можа быць роўнай 0." + }, + { + "id": "model.config.is_valid.data_retention.file_retention_hours_too_low.app_error", + "translation": "Колькасць гадзін захоўвання файлаў не можа быць меншай за 0." + }, + { + "id": "model.config.is_valid.data_retention.file_retention_misconfiguration.app_error", + "translation": "Колькасць дзён захоўвання файлаў і гадзін захоўвання файлаў не можа быць больш за 0." + }, + { + "id": "model.config.is_valid.data_retention.message_retention_both_zero.app_error", + "translation": "Колькасць дзён захоўвання паведамленняў і гадзін захоўвання паведамленняў не можа быць роўнай 0." + }, + { + "id": "model.config.is_valid.data_retention.message_retention_hours_too_low.app_error", + "translation": "Колькасць гадзін захоўвання паведамленняў не можа быць меншай за 0." + }, + { + "id": "model.config.is_valid.data_retention.message_retention_misconfiguration.app_error", + "translation": "Колькасць дзён захоўвання паведамленняў і гадзін захоўвання паведамленняў не можа быць больш за 0." + }, + { + "id": "model.config.is_valid.elastic_search.ignored_indexes_dash_prefix.app_error", + "translation": "Ігнараваныя індэксы для ачысткі не павінны пачынацца з працяжніка." + }, + { + "id": "model.config.is_valid.image_decoder_concurrency.app_error", + "translation": "Недапушчальная сумяшчальнасць дэкодэр {{.Value}}. Павінна быць станоўчая лічба або -1." + }, + { + "id": "model.config.is_valid.link_metadata_timeout.app_error", + "translation": "Недапушчальнае значэнне тайм-аўту метададзеных спасылкі. Павінна быць станоўчая лічба." + }, + { + "id": "model.config.is_valid.local_mode_socket.app_error", + "translation": "Немагчыма знайсці каталог файла лакальнага сокета." + }, + { + "id": "model.config.is_valid.log.advanced_logging.json", + "translation": "Не атрымалася разабраць JSON: {{.Error}}" + }, + { + "id": "model.config.is_valid.log.advanced_logging.parse", + "translation": "Недапушчальны фармат: {{.Error}}" + }, + { + "id": "model.config.is_valid.max_payload_size.app_error", + "translation": "Недапушчальны максімальны памер карыснай нагрузкі для налад службы. Павінна быць цэлая лічба, большая за нуль." + }, + { + "id": "model.config.is_valid.message_export.global_relay.customer_type_custom.app_error", + "translation": "Калі GlobalRelaySettings.CustomerType роўны 'CUSTOM', то GlobalRelaySettings.CustomSMTPServerName і GlobalRelaySettings.CustomSMTPPort павінны быць устаноўлены." + }, + { + "id": "model.config.is_valid.move_thread.domain_invalid.app_error", + "translation": "Недапушчальны дамен для налад перамяшчэння гілкі." + }, + { + "id": "model.config.is_valid.outgoing_integrations_request_timeout.app_error", + "translation": "Недапушчальны час чакання запыту выходных інтэграцый для налад службы. Павінна быць станоўчая лічба." + }, + { + "id": "model.config.is_valid.persistent_notifications_count.app_error", + "translation": "Недапушчальная агульная колькасць пастаянных апавяшчэнняў на пост. Павінна быць станоўчая лічба." + }, + { + "id": "model.config.is_valid.user_status_away_timeout.app_error", + "translation": "Няправільнае значэнне для часу чакання адсутнасці карыстальніка. Павінен быць станоўчым лікам." + }, + { + "id": "model.draft.is_valid.channel_id.app_error", + "translation": "Няправільны ідэнтыфікатар канала." + }, + { + "id": "model.draft.is_valid.create_at.app_error", + "translation": "Дата стварэння павінна быць сапраўдным часам." + }, + { + "id": "model.draft.is_valid.file_ids.app_error", + "translation": "Няправільныя ідэнтыфікатары файлаў." + }, + { + "id": "model.draft.is_valid.priority.app_error", + "translation": "Няправільны прыярытэт" + }, + { + "id": "model.draft.is_valid.props.app_error", + "translation": "Няправільныя ўласцівасці." + }, + { + "id": "model.draft.is_valid.root_id.app_error", + "translation": "Няправільны ідэнтыфікатар кораня." + }, + { + "id": "model.draft.is_valid.update_at.app_error", + "translation": "Дата абнаўлення павінна быць сапраўдным часам." + }, + { + "id": "model.draft.is_valid.user_id.app_error", + "translation": "Няправільны ідэнтыфікатар карыстальніка." + }, + { + "id": "model.group.name.reserved_name.app_error", + "translation": "Назва групы ўжо існуе як зарэзерваваная назва" + }, + { + "id": "model.license_record.is_valid.bytes.app_error", + "translation": "Няправільнае значэнне для байтаў пры загрузцы ліцэнзіі." + }, + { + "id": "model.outgoing_oauth_connection.is_valid.audience.empty", + "translation": "Аўдыторыя не павінна быць пустой." + }, + { + "id": "model.outgoing_oauth_connection.is_valid.audience.error", + "translation": "URL аўдыторыі няправільны: {{ .Url }}" + }, + { + "id": "model.outgoing_oauth_connection.is_valid.client_id.error", + "translation": "Няправільны ідэнтыфікатар кліента." + }, + { + "id": "model.outgoing_oauth_connection.is_valid.client_secret.error", + "translation": "Няправільны сакрэт кліента." + }, + { + "id": "model.outgoing_oauth_connection.is_valid.create_at.error", + "translation": "Поле \"Створана\" павінна ўтрымліваць правільны час." + }, + { + "id": "model.outgoing_oauth_connection.is_valid.creator_id.error", + "translation": "Няправільны ідэнтыфікатар стваральніка." + }, + { + "id": "model.outgoing_oauth_connection.is_valid.grant_type.error", + "translation": "Няправільны тып доступу." + }, + { + "id": "model.outgoing_oauth_connection.is_valid.id.error", + "translation": "Няправільны ідэнтыфікатар." + }, + { + "id": "model.outgoing_oauth_connection.is_valid.name.error", + "translation": "Няправільнае імя." + }, + { + "id": "model.outgoing_oauth_connection.is_valid.oauth_token_url.error", + "translation": "Няправільны URL токена oauth." + }, + { + "id": "model.outgoing_oauth_connection.is_valid.password_credentials.error", + "translation": "Няправільны пароль." + }, + { + "id": "model.outgoing_oauth_connection.is_valid.update_at.error", + "translation": "Час абнаўлення павінен быць сапраўдным." + }, + { + "id": "model.reporting_base_options.is_valid.bad_date_range", + "translation": "Пададзены дыяпазон дат няправільны." + }, + { + "id": "model.user_report_options.is_valid.invalid_sort_column", + "translation": "Зададзены слупок сартавання не з'яўляецца сапраўдным." + }, + { + "id": "basic_security_check.url.too_long_error", + "translation": "URL занадта доўгі" + }, + { + "id": "api.command_share.invite_remote_to_channel.error", + "translation": "Немагчыма запрасіць аддаленага карыстальніка ў канал: {{.Error}}" + }, + { + "id": "api.server.cws.disabled", + "translation": "Узаемадзеянне з парталам кліентаў Mattermost адключана сістэмным адміністратарам." + }, + { + "id": "api.team.update_team_member_roles.user_and_guest.app_error", + "translation": "Няправільнае абнаўленне ўдзельніка каманды: госць не можа быць прызначаны для адной каманды, сістэмны адміністратар павінен павышаць або паніжаць карыстальнікаў да/з статусу госця." + }, + { + "id": "model.user.is_valid.invalidProperty.app_error", + "translation": "Няправільныя ўласцівасці (карыстальніцкі статус)" + }, + { + "id": "api.channel.update_channel_member_roles.guest.app_error", + "translation": "Някарэктнае абнаўленне ўдзельніка канала: госць не можа быць прызначаны членам каманды або адміністратарам каманды, калі ласка, спачатку павысіце яго да карыстальніка." + }, + { + "id": "api.channel.update_channel_member_roles.user_and_guest.app_error", + "translation": "Някарэктнае абнаўленне ўдзельніка канала: госць не можа быць прызначаны для аднаго канала, сістэмны адміністратар павінен павышаць або паніжаць карыстальнікаў да/з статусу госця." + }, + { + "id": "api.channel.bookmark.channel_bookmark.license.error", + "translation": "Ваша ліцэнзія не падтрымлівае закладкі каналаў." + }, + { + "id": "api.channel.bookmark.create_channel_bookmark.direct_or_group_channels.forbidden.app_error", + "translation": "Карыстальнік не мае права ствараць закладку канала." + }, + { + "id": "api.channel.bookmark.create_channel_bookmark.direct_or_group_channels_by_guests.forbidden.app_error", + "translation": "Не атрымалася стварыць закладку канала." + }, + { + "id": "api.channel.bookmark.create_channel_bookmark.forbidden.app_error", + "translation": "Не атрымалася стварыць закладку канала." + }, + { + "id": "api.channel.bookmark.delete_channel_bookmark.direct_or_group_channels.forbidden.app_error", + "translation": "Не атрымалася выдаліць закладку канала." + }, + { + "id": "api.channel.bookmark.delete_channel_bookmark.direct_or_group_channels_by_guests.forbidden.app_error", + "translation": "Не атрымалася выдаліць закладку канала." + }, + { + "id": "api.channel.bookmark.delete_channel_bookmark.forbidden.app_error", + "translation": "Не атрымалася выдаліць закладку канала." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark.direct_or_group_channels.forbidden.app_error", + "translation": "Не атрымалася абнавіць закладку канала." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark.direct_or_group_channels_by_guests.forbidden.app_error", + "translation": "Не атрымалася абнавіць закладку канала." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark.forbidden.app_error", + "translation": "Не атрымалася абнавіць закладку канала." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark_sort_order.direct_or_group_channels.forbidden.app_error", + "translation": "Не атрымалася абнавіць парадак сартавання закладок канала." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark_sort_order.direct_or_group_channels_by_guests.forbidden.app_error", + "translation": "Не атрымалася абнавіць парадак сартавання закладок канала." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark_sort_order.forbidden.app_error", + "translation": "Не атрымалася абнавіць парадак сартавання закладок канала." + }, + { + "id": "app.channel.bookmark.delete.app_error", + "translation": "Не атрымалася выдаліць закладку." + }, + { + "id": "app.channel.bookmark.get.app_error", + "translation": "Не атрымалася атрымаць закладку." + }, + { + "id": "app.channel.bookmark.get_existing.app_err", + "translation": "Не атрымалася абнавіць існуючую закладку." + }, + { + "id": "app.channel.bookmark.save.app_error", + "translation": "Не атрымалася захаваць закладку." + }, + { + "id": "app.channel.bookmark.update.app_error", + "translation": "Не атрымалася абнавіць закладку." + }, + { + "id": "app.channel.bookmark.update_sort.app_error", + "translation": "Не атрымалася адсартаваць закладку." + }, + { + "id": "app.channel.bookmark.update_sort.invalid_input.app_error", + "translation": "Не атрымалася адсартаваць закладку. Няправільныя ўведзеныя даныя." + }, + { + "id": "app.channel.bookmark.update_sort.missing_bookmark.app_error", + "translation": "Не атрымалася адсартаваць закладку. Не знойдзена." + }, + { + "id": "model.channel_bookmark.is_valid.channel_id.app_error", + "translation": "Няправільны ідэнтыфікатар канала." + }, + { + "id": "model.channel_bookmark.is_valid.create_at.app_error", + "translation": "Поле \"Створана\" павінна быць сапраўдным часам." + }, + { + "id": "model.channel_bookmark.is_valid.display_name.app_error", + "translation": "Назва для адлюстравання адсутнічае." + }, + { + "id": "model.channel_bookmark.is_valid.file_id.missing_or_invalid.app_error", + "translation": "Ідэнтыфікатар файла адсутнічае або несапраўдны." + }, + { + "id": "model.channel_bookmark.is_valid.id.app_error", + "translation": "Няправільны ідэнтыфікатар." + }, + { + "id": "model.channel_bookmark.is_valid.image_url.app_error", + "translation": "Няправільны URL выявы." + }, + { + "id": "model.channel_bookmark.is_valid.link_file.app_error", + "translation": "Немагчыма ўсталяваць спасылку і файл у адной закладцы." + }, + { + "id": "model.channel_bookmark.is_valid.link_url.missing_or_invalid.app_error", + "translation": "URL спасылкі адсутнічае або несапраўдны." + }, + { + "id": "model.channel_bookmark.is_valid.original_id.app_error", + "translation": "Няправільны зыходны ідэнтыфікатар." + }, + { + "id": "model.channel_bookmark.is_valid.owner_id.app_error", + "translation": "Няправільны ідэнтыфікатар уладальніка." + }, + { + "id": "model.channel_bookmark.is_valid.parent_id.app_error", + "translation": "Няправільны ідэнтыфікатар бацькоўскага элемента." + }, + { + "id": "model.channel_bookmark.is_valid.type.app_error", + "translation": "Няправільны тып." + }, + { + "id": "model.channel_bookmark.is_valid.update_at.app_error", + "translation": "Час абнаўлення павінен быць сапраўдным часам." + }, + { + "id": "api.config.update.elasticsearch.autocomplete_cannot_be_enabled_error", + "translation": "Аўтадапаўненне каналаў не можа быць уключана, бо схема індэкса каналаў састарэла. Рэкамендуецца перагенерыць індэкс каналаў. Глядзіце часопіс зменаў Mattermost для атрымання дадатковай інфармацыі" + }, + { + "id": "ent.elasticsearch.purge_index.delete_failed", + "translation": "Не атрымалася выдаліць індэкс пошуку." + }, + { + "id": "ent.elasticsearch.purge_indexes.unknown_index", + "translation": "Не атрымалася выдаліць невядомы пазначаны індэкс." + }, + { + "id": "api.user.create_user.user_limits.exceeded", + "translation": "Немагчыма стварыць карыстальніка. Сервер перавышае бяспечны ліміт карыстальнікаў. Звярніцеся да адміністратара з паведамленнем: ERROR_SAFETY_LIMITS_EXCEEDED." + }, + { + "id": "app.session.get_lru_sessions.app_error", + "translation": "Не атрымалася атрымаць найменш нядаўна выкарыстаныя сесіі." + }, + { + "id": "app.user.update_active.user_limit.exceeded", + "translation": "Немагчыма актываваць карыстальніка. Сервер перавышае бяспечны ліміт карыстальнікаў. ERROR_SAFETY_LIMITS_EXCEEDED." + }, + { + "id": "app.import.import_line.null_role.error", + "translation": "Радок даных для імпарту мае тып \"role\", але аб'ект ролі мае значэнне null." + }, + { + "id": "app.scheme.get_all_page.app_error", + "translation": "Не атрымалася атрымаць старонку схем." + }, + { + "id": "model.scheme.is_valid.app_error", + "translation": "Няправільная схема." + }, + { + "id": "api.post.check_for_out_of_team_mentions.message.multiple", + "translation": "@{{.Usernames}} і @{{.LastUsername}} не былі апавешчаны пра гэтае згадванне, бо яны не з'яўляюцца ўдзельнікамі гэтай каманды." + }, + { + "id": "api.post.check_for_out_of_team_mentions.message.one", + "translation": "@{{.Username}} не быў апавешчаны пра гэтае згадванне, бо ён не з'яўляецца ўдзельнікам гэтай каманды." + }, + { + "id": "api.channel.create_channel.missing_display_name.error", + "translation": "Адсутнічае display_name у целе запыту" + }, + { + "id": "api.channel.create_channel.missing_team_id.error", + "translation": "Адсутнічае team_id у целе запыту" + }, + { + "id": "api.user.auth_switch.not_available.email_signup_disabled.app_error", + "translation": "Перадача аўтэнтыфікацыі недаступная, бо рэгістрацыя па электроннай пошце адключана." + }, + { + "id": "api.user.auth_switch.not_available.login_disabled.app_error", + "translation": "Перадача аўтэнтыфікацыі недаступная, бо не ўключана ні ўваход па электроннай пошце, ні ўваход па імені карыстальніка." + }, + { + "id": "api4.plugin.reattachPlugin.invalid_request", + "translation": "Не атрымалася апрацаваць запыт." + }, + { + "id": "app.plugin.reattach.app_error", + "translation": "Не атрымалася паўторна падключыць плагін." + }, + { + "id": "plugin_reattach_request.is_valid.manifest.app_error", + "translation": "Маніфест адсутнічае." + }, + { + "id": "plugin_reattach_request.is_valid.plugin_reattach_config.app_error", + "translation": "Канфігурацыя паўторнага падключэння плагіна адсутнічае." + }, + { + "id": "model.user.is_valid.pwd_max_length.app_error", + "translation": "Ваш пароль не павінен змяшчаць больш за 72 сімвалаў." + }, + { + "id": "model.user.is_valid.pwd_min_length.app_error", + "translation": "Ваш пароль павінен змяшчаць не менш за {{.Min}} сімвалаў." + }, + { + "id": "app.import.attachment.file_stat.error", + "translation": "Памылка пры чытанні статусу файла: \"{{.FilePath}}\"" + }, + { + "id": "app.import.attachment.seek_file.error", + "translation": "Памылка пошуку файла: \"{{.FilePath}}\"" + }, + { + "id": "app.limits.get_app_limits.user_count.store_error", + "translation": "Не атрымалася атрымаць колькасць карыстальнікаў." + }, + { + "id": "store.sql_team.save_team.existing.app_error", + "translation": "Каманда з такім URL ужо існуе." + }, + { + "id": "app.user.update.countAdmins.app_error", + "translation": "Памылка пры вызначэнні колькасці ўліковых запісаў сістэмнага адміністратара." + }, + { + "id": "app.user.update.lastAdmin.app_error", + "translation": "Немагчыма панізіць апошняга сістэмнага адміністратара." + }, + { + "id": "app.user.save.groupname.app_error", + "translation": "Не атрымалася вызначыць, ці існуе ўжо такое імя карыстальніка." + }, + { + "id": "api.job.status.invalid", + "translation": "Ўсталяваны няверны статус" + }, + { + "id": "api.job.unable_to_manage_job.incorrect_job_type", + "translation": "Вы не маеце дазволу кіраваць гэтым тыпам задання" + }, + { + "id": "app.job.update_status.app_error", + "translation": "Не атрымалася абнавіць статус задання. Няправільны ўсталяваны статус." + }, + { + "id": "api.system.logs.download_bytes_buffer.app_error", + "translation": "Не атрымалася запісаць журналы ў буфер." + }, + { + "id": "api.user.update_password.password_hash.app_error", + "translation": "Адбылася ўнутраная памылка пры захаванні пароля." + }, + { + "id": "model.user.pre_save.password_hash.app_error", + "translation": "Адбылася ўнутраная памылка пры захаванні пароля." + }, + { + "id": "model.user.pre_save.password_too_long.app_error", + "translation": "Ваш пароль не павінен змяшчаць больш за 72 сімвалаў." + }, + { + "id": "api.get_site_url_error", + "translation": "Не атрымалася атрымаць URL сайта інстанса" + }, + { + "id": "api.remote_cluster.accept_invitation_error", + "translation": "Не атрымалася прыняць запрашэнне аддаленага кластера." + }, + { + "id": "api.remote_cluster.base64_decode_error", + "translation": "Не атрымалася дэкадаваць base64 радок." + }, + { + "id": "api.remote_cluster.cluster_not_deleted", + "translation": "Аддалены кластер не быў выдалены." + }, + { + "id": "api.remote_cluster.encrypt_invite_error", + "translation": "Не атрымалася зашыфраваць запрашэнне аддаленага кластера з дапамогай пададзенага пароля." + }, + { + "id": "api.remote_cluster.get.not_found", + "translation": "Аддалены кластер не знойдзены." + }, + { + "id": "api.remote_cluster.invite_decrypt_error", + "translation": "Не атрымалася дэшыфраваць запрашэнне аддаленага кластера з дапамогай пададзенага пароля." + }, + { + "id": "app.import.custom_status.error", + "translation": "Не атрымалася ўсталяваць карыстальніцкі статус." + }, + { + "id": "model.config.is_valid.elastic_search.invalid_backend.app_error", + "translation": "Няправільны бэкэнд пошуку. Павінен быць elasticsearch або opensearch." + }, + { + "id": "api.context.request_body_too_large.app_error", + "translation": "Не атрымалася апрацаваць запыт. Цела запыту занадта вялікае." + }, + { + "id": "model.config.is_valid.max_url_length.app_error", + "translation": "Няправільная максімальная даўжыня URL для налад сервера. Павінна быць цэлым лікам, большым за нуль." + }, + { + "id": "model.preference.is_valid.limit_visible_dms_gms.app_error", + "translation": "Няправільнае значэнне для limit_visible_dms_gms." + }, + { + "id": "model.config.is_valid.cache_type.app_error", + "translation": "Тып кэша павінен быць lru або redis." + }, + { + "id": "model.config.is_valid.empty_redis_address.app_error", + "translation": "RedisAddress павінен быць пазначаны для тыпу кэша redis." + }, + { + "id": "model.config.is_valid.invalid_redis_db.app_error", + "translation": "Redis DB павінен мець значэнне, большае або роўнае нулю." + }, + { + "id": "app.webhooks.get_incoming_count.app_error", + "translation": "Не атрымалася атрымаць вэбхук для teamID={{.TeamID}}, userID={{.UserID}}, err={{.Error}}." + }, + { + "id": "api.user.login_with_desktop_token.not_oauth_or_saml_user.app_error", + "translation": "Карыстальнік не з'яўляецца карыстальнікам OAuth або SAML." + }, + { + "id": "api.shared_channel.get_shared_channel_remotes_error", + "translation": "Не атрымалася атрымаць аддаленыя агульныя каналы." + }, + { + "id": "api.shared_channel.has_remote_error", + "translation": "Не атрымалася вызначыць, ці з'яўляецца канал агульным з аддаленым." + }, + { + "id": "api.shared_channel.invite_remote_to_channel_error", + "translation": "Не атрымалася запрасіць аддалены доступ да канала." + }, + { + "id": "api.shared_channel.uninvite_remote_to_channel_error", + "translation": "Не атрымалася адмяніць запрашэнне аддаленага доступу да канала." + }, + { + "id": "app.import.import_direct_channel.get_channel_members.error", + "translation": "Не атрымалася атрымаць удзельнікаў канала для прыватнага канала." + }, + { + "id": "app.import.import_direct_channel.no_members.error", + "translation": "У прамым канале няма ўдзельнікаў" + }, + { + "id": "app.post.save.thread_membership.app_error", + "translation": "Немагчыма захаваць удзельнікаў гілкі для паведамлення." + }, + { + "id": "app.thread.get_threadmembers_for_export.app_error", + "translation": "Немагчыма атрымаць удзельнікаў гілкі для экспарту." + }, + { + "id": "model.thread.is_valid.post_id.app_error", + "translation": "Няправільны ідэнтыфікатар паведамлення." + }, + { + "id": "model.thread.is_valid.user_id.app_error", + "translation": "Няправільны ідэнтыфікатар карыстальніка." + }, + { + "id": "api.plugin.upload.file_too_large.app_error", + "translation": "Памер загружанага плагіна перавышае ліміт. Гэты ліміт можна змяніць у сістэмнай кансолі праз \"Сховішча файлаў\" > \"Максімальны памер файла\"" + }, + { + "id": "api.upload.create.upload_channel_not_shared_with_remote.app_error", + "translation": "Не атрымалася загрузіць файл. Канал загрузкі не абагулены з аддаленым серверам." + }, + { + "id": "app.session.set_extra_session_prop.app_error", + "translation": "Немагчыма абнавіць дадатковыя ўласцівасці сесіі." + }, + { + "id": "api.remote_cluster.generate_invite_cluster_is_confirmed", + "translation": "Немагчыма стварыць код запрашэння для пацверджанага кластара" + }, + { + "id": "api.post.delete_post.not_enabled.app_error", + "translation": "Немагчыма выдаліць паведамленне, ServiceSettings.EnableAPIPostDeletion не ўключана." + }, + { + "id": "api.user.update_active.cannot_modify_status_when_user_is_managed_by_ldap.app_error", + "translation": "Вы не можаце змяняць статус карыстальніка. Карыстальнік кіруецца праз LDAP" + }, + { + "id": "app.file_info.get_by_post_id.app_error", + "translation": "Не атрымалася знайсці файлы для паведамлення." + }, + { + "id": "app.file_info.permanent_delete_for_post.app_error", + "translation": "Не атрымалася канчаткова выдаліць файл для паведамлення." + }, + { + "id": "app.post.permanent_delete_post.error", + "translation": "Не атрымалася канчаткова выдаліць паведамленне." + }, + { + "id": "api.channel.create_channel.direct_channel.remote_restricted.app_error", + "translation": "Немагчыма стварыць прамы канал з аддаленым карыстальнікам" + }, + { + "id": "api.channel.create_group.remote_restricted.app_error", + "translation": "Немагчыма стварыць групавы канал з аддаленымі карыстальнікамі" + }, + { + "id": "api.license.add_license.copy.app_error", + "translation": "Не атрымалася скапіяваць змест файла ліцэнзіі ў буфер" + }, + { + "id": "api.remote_cluster.create_invite_error", + "translation": "Не атрымалася стварыць запрашэнне для аддаленага кластара" + }, + { + "id": "app.post.create_post.shared_dm_or_gm.app_error", + "translation": "Немагчыма стварыць паведамленне ў прамым або групавым канале з аддаленымі карыстальнікамі" + }, + { + "id": "model.remote_cluster_invite.is_valid.remote_id.app_error", + "translation": "Няправільны аддалены ідэнтыфікатар." + }, + { + "id": "model.remote_cluster_invite.is_valid.site_url.app_error", + "translation": "Няправільны URL сайта." + }, + { + "id": "model.remote_cluster_invite.is_valid.token.app_error", + "translation": "Няправільны токен." + }, + { + "id": "api.oauth.get_access_token.bad_request.app_error", + "translation": "invalid_request: Няправільны запыт." + }, + { + "id": "api.scheduled_posts.feature_disabled", + "translation": "функцыя запланаваных паведамленняў адключана" + }, + { + "id": "api.scheduled_posts.license_error", + "translation": "Функцыя запланаваных паведамленняў патрабуе ліцэнзіі" + }, + { + "id": "app.bot.update.app_error", + "translation": "Немагчыма абнавіць бота." + }, + { + "id": "app.delete_scheduled_post.delete_error", + "translation": "Не атрымалася выдаліць запланаванае паведамленне з базы дадзеных." + }, + { + "id": "app.delete_scheduled_post.delete_permission.error", + "translation": "У вас няма дазволу на выдаленне гэтага рэсурсу." + }, + { + "id": "app.delete_scheduled_post.existing_scheduled_post.not_exist", + "translation": "Запланаванага паведамлення не існуе." + }, + { + "id": "app.delete_scheduled_post.get_scheduled_post.error", + "translation": "Немагчыма атрымаць існуючае запланаванае паведамленне з базы дадзеных." + }, + { + "id": "app.get_user_team_scheduled_posts.error", + "translation": "Памылка пры атрыманні запланаваных паведамленняў." + }, + { + "id": "app.import.import_bot.owner_could_not_found.error", + "translation": "Немагчыма знайсці ўладальніка бота" + }, + { + "id": "app.import.import_line.null_bot.error", + "translation": "Радок дадзеных для імпарту мае тып \"бот\", але аб'ект бота - null" + }, + { + "id": "app.import.validate_bot_import_data.owner_missing.error", + "translation": "Уладальнік бота адсутнічае" + }, + { + "id": "app.save_scheduled_post.channel_deleted.app_error", + "translation": "Немагчыма запланаваць паведамленне ў заархіваваным канале." + }, + { + "id": "app.save_scheduled_post.save.app_error", + "translation": "Памылка пры захаванні запланаванага паведамлення." + }, + { + "id": "app.update_scheduled_post.existing_scheduled_post.not_exist", + "translation": "Запланаванага паведамлення не існуе." + }, + { + "id": "app.update_scheduled_post.get_scheduled_post.error", + "translation": "Немагчыма атрымаць існуючае запланаванае паведамленне з базы дадзеных." + }, + { + "id": "app.update_scheduled_post.update.error", + "translation": "Не атрымалася захаваць абноўленае запланаванае паведамленне ў базе дадзеных." + }, + { + "id": "app.update_scheduled_post.update_permission.error", + "translation": "У вас няма дазволу на абнаўленне гэтага рэсурсу." + }, + { + "id": "model.scheduled_post.is_valid.empty_post.app_error", + "translation": "Немагчыма запланаваць пустое паведамленне. Запланаванае паведамленне павінна ўтрымліваць прынамсі тэкст або прымацаваныя файлы." + }, + { + "id": "model.scheduled_post.is_valid.id.app_error", + "translation": "Запланаванае паведамленне павінна мець ідэнтыфікатар." + }, + { + "id": "model.scheduled_post.is_valid.processed_at.app_error", + "translation": "Няправільны час апрацоўкі." + }, + { + "id": "model.scheduled_post.is_valid.scheduled_at.app_error", + "translation": "Няправільны час планавання." + }, + { + "id": "app.notifications.send_test_message.errors.create_post", + "translation": "Паведамленне не можа быць створана" + }, + { + "id": "app.notifications.send_test_message.errors.no_bot", + "translation": "Немагчыма атрымаць сістэмнага бота" + }, + { + "id": "app.notifications.send_test_message.errors.no_channel", + "translation": "Немагчыма атрымаць прамое паведамленне ад сістэмнага бота" + }, + { + "id": "app.notifications.send_test_message.errors.no_user", + "translation": "Немагчыма атрымаць карыстальніка" + }, + { + "id": "app.notifications.send_test_message.message_body", + "translation": "Калі вы атрымалі гэтае тэставае апавяшчэнне, усё працуе!" + }, + { + "id": "app.file_info.seek.gif.app_error", + "translation": "Немагчыма перамясціцца да пачатку даных GIF." + }, + { + "id": "app.scheduled_post.error_reason.channel_archived", + "translation": "Канал заархіваваны" + }, + { + "id": "app.scheduled_post.error_reason.channel_not_found", + "translation": "Канал не знойдзены" + }, + { + "id": "app.scheduled_post.error_reason.invalid_post", + "translation": "Няправільнае змест паведамлення" + }, + { + "id": "app.scheduled_post.error_reason.no_channel_member", + "translation": "Не з'яўляецеся ўдзельнікам канала" + }, + { + "id": "app.scheduled_post.error_reason.no_channel_permission", + "translation": "Няма дазволу на публікацыю ў канале" + }, + { + "id": "app.scheduled_post.error_reason.thread_deleted", + "translation": "Гілка была выдалена" + }, + { + "id": "app.scheduled_post.error_reason.unable_to_send", + "translation": "Немагчыма адправіць паведамленне" + }, + { + "id": "app.scheduled_post.error_reason.unknown", + "translation": "Невядомая памылка" + }, + { + "id": "app.scheduled_post.error_reason.user_deleted", + "translation": "Уліковы запіс карыстальніка выдалены" + }, + { + "id": "app.scheduled_post.error_reason.user_missing", + "translation": "Карыстальнік не існуе" + }, + { + "id": "app.scheduled_post.permanent_delete_by_user.app_error", + "translation": "Немагчыма выдаліць запланаваныя паведамленні для карыстальніка." + }, + { + "id": "app.scheduled_post.private_channel", + "translation": "Прыватны канал" + }, + { + "id": "app.scheduled_post.unknown_channel", + "translation": "Невядомы канал" + }, + { + "id": "model.config.is_valid.storage_class.app_error", + "translation": "Няправільны клас сховішча {{.Value}}." + }, + { + "id": "api.post.deduplicate_create_post.cache_error", + "translation": "Не атрымалася закешыраваць паведамленне пасля дэдуплікацыі кліента, які паўтарае аднолькавы запыт." + }, + { + "id": "app.plugin.seek.app_error", + "translation": "Немагчыма скінуць пазіцыю чытання да пачатку пакета плагіна." + }, + { + "id": "web.command_webhook.general.app_error", + "translation": "Не атрымалася апрацаваць камандны вебхук {{.hook_id}}." + }, + { + "id": "web.incoming_webhook.decode.app_error", + "translation": "Не атрымалася дэкадаваць payload медыятыпу {{.media_type}} для ўваходнага вебхука {{.hook_id}}." + }, + { + "id": "web.incoming_webhook.general.app_error", + "translation": "Не атрымалася апрацаваць payload медыятыпу {{.media_type}} для ўваходнага вебхука {{.hook_id}}." + }, + { + "id": "web.incoming_webhook.media_type.app_error", + "translation": "Не атрымалася разабраць медыя ўваходнага вебхука {{.hook_id}}." + }, + { + "id": "model.draft.is_valid.message_length.app_error", + "translation": "Уласцівасць \"Чарнавік паведамлення\" даўжэйшая за максімальна дапушчальную даўжыню." + }, + { + "id": "model.post.is_valid.message_length.app_error", + "translation": "Уласцівасць \"Паведамленне\" даўжэйшая за максімальна дапушчальную даўжыню." + }, + { + "id": "api.filter_config_error", + "translation": "Немагчыма адфільтраваць канфігурацыю." + }, + { + "id": "app.export.export_custom_emoji.mkdir.error", + "translation": "Не атрымалася стварыць каталог для выяваў нестандартных эмодзі" + }, + { + "id": "app.import.validate_user_import_data.guest_roles_conflict.error", + "translation": "Ролі карыстальнікаў не адпавядаюць статусу госця." + }, + { + "id": "app.custom_profile_attributes.cpa_group_id.app_error", + "translation": "Немагчыма зарэгістраваць групу ўласцівасцей \"Атрыбуты карыстальніцкага профілю\"" + }, + { + "id": "app.custom_profile_attributes.create_property_field.app_error", + "translation": "Немагчыма стварыць поле \"Атрыбут карыстальніцкага профілю\"" + }, + { + "id": "app.custom_profile_attributes.get_property_field.app_error", + "translation": "Немагчыма атрымаць поле \"Атрыбут карыстальніцкага профілю\"" + }, + { + "id": "app.custom_profile_attributes.limit_reached.app_error", + "translation": "Дасягнуты ліміт поля \"Атрыбуты карыстальніцкага профілю\"" + }, + { + "id": "app.custom_profile_attributes.list_property_values.app_error", + "translation": "Немагчыма атрымаць значэнні атрыбутаў карыстальніцкага профілю" + }, + { + "id": "app.custom_profile_attributes.property_field_delete.app_error", + "translation": "Немагчыма выдаліць поле \"Атрыбут карыстальніцкага профілю\"" + }, + { + "id": "app.custom_profile_attributes.property_field_not_found.app_error", + "translation": "Поле \"Атрыбут карыстальніцкага профілю\" не знойдзена" + }, + { + "id": "app.custom_profile_attributes.property_field_update.app_error", + "translation": "Немагчыма абнавіць поле \"Атрыбут карыстальніцкага профілю\"" + }, + { + "id": "app.custom_profile_attributes.search_property_fields.app_error", + "translation": "Немагчыма шукаць палі \"Атрыбуты карыстальніцкага профілю\"" + }, + { + "id": "app.file_info.delete_for_post_ids.app_error", + "translation": "Не атрымалася выдаліць запытаныя файлы з базы дадзеных" + }, + { + "id": "app.file_info.get_by_ids.app_error", + "translation": "Немагчыма атрымаць інфармацыю пра файлы па ідэнтыфікатарах для гісторыі рэдагавання паведамлення." + }, + { + "id": "app.file_info.undelete_for_post_ids.app_error", + "translation": "Не атрымалася аднавіць прымацаваныя файлы паведамлення." + }, + { + "id": "app.post.restore_post_version.get_single.app_error", + "translation": "Не атрымалася атрымаць старую версію паведамлення." + }, + { + "id": "app.post.restore_post_version.not_allowed.app_error", + "translation": "Вы не маеце адпаведных дазволаў." + }, + { + "id": "app.post.restore_post_version.not_an_history_item.app_error", + "translation": "Прадстаўлены ідэнтыфікатар гісторыі паведамлення не адпавядае ніводнаму элементу гісторыі для пазначанага паведамлення." + }, + { + "id": "app.post.restore_post_version.not_valid_post_history_item.app_error", + "translation": "Прадстаўлены ідэнтыфікатар гісторыі паведамлення не адпавядае элементу гісторыі паведамлення." + }, + { + "id": "app.role.delete.app_error", + "translation": "Немагчыма выдаліць ролю." + }, + { + "id": "ent.message_export.actiance_export.calculate_channel_exports.activity_message", + "translation": "Разлік актыўнасці каналаў: {{.NumCompleted}}/{{.NumChannels}} каналаў завершана." + }, + { + "id": "ent.message_export.actiance_export.calculate_channel_exports.channel_message", + "translation": "Экспарт інфармацыі пра каналы для {{.NumChannels}} каналаў." + }, + { + "id": "ent.message_export.calculate_channel_exports.app_error", + "translation": "Не атрымалася разлічыць даныя для экспарту канала." + }, + { + "id": "ent.message_export.job_data_conversion.app_error", + "translation": "Не атрымалася пераўтварыць значэнне з поля даных задання." + }, + { + "id": "model.link_metadata.is_valid.url_length.app_error", + "translation": "Даўжыня URL метаданых спасылкі складае {{ .Length }} сімвалаў, што перавышае максімальны ліміт у {{ .MaxLength }} сімвалаў." + }, + { + "id": "model.property_field.is_valid.app_error", + "translation": "Няправільнае поле ўласцівасці: {{.FieldName}} ({{.Reason}})." + }, + { + "id": "model.property_value.is_valid.app_error", + "translation": "Няправільнае значэнне ўласцівасці: {{.FieldName}} ({{.Reason}})." + }, + { + "id": "api.custom_profile_attributes.license_error", + "translation": "Ваша ліцэнзія не падтрымлівае атрыбуты карыстальніцкага профілю." + }, + { + "id": "api.command.execute_command.deleted.error", + "translation": "Немагчыма выканаць каманду ў выдаленым канале." + }, + { + "id": "api.channel.bookmark.create_channel_bookmark.deleted_channel.forbidden.app_error", + "translation": "Не атрымалася стварыць закладку канала." + }, + { + "id": "api.channel.bookmark.delete_channel_bookmark.deleted_channel.forbidden.app_error", + "translation": "Не атрымалася выдаліць закладку канала." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark.deleted_channel.forbidden.app_error", + "translation": "Не атрымалася абнавіць закладку канала." + }, + { + "id": "api.channel.bookmark.update_channel_bookmark_sort_order.deleted_channel.forbidden.app_error", + "translation": "Не атрымалася абнавіць парадак сартавання закладак канала." + }, + { + "id": "app.file_info.get_count.app_error", + "translation": "Не атрымалася атрымаць колькасць усіх файлаў." + }, + { + "id": "app.file_info.get_storage_usage.app_error", + "translation": "Не атрымалася атрымаць выкарыстанне сховішча для ўсіх файлаў." + }, + { + "id": "api.context.get_session.app_error", + "translation": "Сеанс не знойдзены." + }, + { + "id": "app.custom_profile_attributes.count_property_fields.app_error", + "translation": "Немагчыма падлічыць колькасць палёў для групы атрыбутаў карыстальніцкага профілю" + }, + { + "id": "app.custom_profile_attributes.property_value_upsert.app_error", + "translation": "Немагчыма дадаць/абнавіць палі атрыбутаў карыстальніцкага профілю" + }, + { + "id": "model.config.is_valid.metrics_client_side_user_id.app_error", + "translation": "Няправільны ідэнтыфікатар карыстальніка на баку кліента: {{.Id}}" + }, + { + "id": "model.config.is_valid.metrics_client_side_user_ids.app_error", + "translation": "Колькасць элементаў у ClientSideUserIds {{.CurrentLength}} перавышае максімальнае значэнне {{.MaxLength}}." + }, + { + "id": "api.channel.update_channel.banner_info.channel_type.not_allowed", + "translation": "Банэр канала можна наладзіць толькі на публічных і прыватных каналах." + }, + { + "id": "model.channel.is_valid.banner_info.background_color.empty.app_error", + "translation": "Колер банера канала не можа быць пустым, калі банер канала ўключаны" + }, + { + "id": "model.channel.is_valid.banner_info.channel_type.app_error", + "translation": "Банэр канала можна наладзіць толькі на публічных і прыватных каналах" + }, + { + "id": "model.channel.is_valid.banner_info.text.empty.app_error", + "translation": "Інфармацыйны тэкст банера канала не можа быць пустым, калі банер канала ўключаны" + }, + { + "id": "model.channel.is_valid.banner_info.text.invalid_length.app_error", + "translation": "Інфармацыйны тэкст банера канала занадта доўгі. Максімальна дапушчальная даўжыня - {{.maxLength}} сімвалаў." + }, + { + "id": "api.user.check_user_login_attempts.too_many_ldap.app_error", + "translation": "Ваш уліковы запіс заблакіраваны з-за занадта вялікай колькасці няўдалых спроб уводу пароля. Звярніцеся да свайго сістэмнага адміністратара." + }, + { + "id": "api.user.reset_password_failed_attempts.ldap_and_email_only.app_error", + "translation": "Сэрвіс аўтэнтыфікацыі карыстальніка павінен быць LDAP або Email." + }, + { + "id": "api.user.reset_password_failed_attempts.permissions.app_error", + "translation": "У вас няма дазволу на абнаўленне гэтага рэсурсу." + }, + { + "id": "app.drafts.permanent_delete_by_user.app_error", + "translation": "Немагчыма выдаліць чарнавікі для карыстальніка." + }, + { + "id": "app.group.license_error", + "translation": "Патрабуецца ліцэнзія LDAP." + }, + { + "id": "app.user.reset_password_failed_attempts.app_error", + "translation": "Не атрымалася скінуць спробы ўваходу." + }, + { + "id": "ent.ldap.get_user_by_auth.app_error", + "translation": "Не атрымалася атрымаць карыстальніка." + }, + { + "id": "license_error.feature_unavailable", + "translation": "Функцыя недаступная для бягучай ліцэнзіі" + }, + { + "id": "model.config.is_valid.ldap_max_login_attempts.app_error", + "translation": "Няправільная максімальная колькасць спроб уваходу для налад LDAP. Павінна быць станоўчым лікам." + }, + { + "id": "app.custom_profile_attributes.property_field_conversion.app_error", + "translation": "Немагчыма пераўтварыць поле ўласцівасці ў поле атрыбута карыстальніцкага профілю" + }, + { + "id": "app.custom_profile_attributes.sanitize_and_validate.app_error", + "translation": "Няправільныя атрыбуты значэнняў уласцівасцей: {{.AttributeName}} ({{.Reason}})." + }, + { + "id": "model.access_policy.is_valid.id.app_error", + "translation": "Няправільны ідэнтыфікатар палітыкі." + }, + { + "id": "model.access_policy.is_valid.imports.app_error", + "translation": "Няправільны імпарт для палітыкі." + }, + { + "id": "model.access_policy.is_valid.name.app_error", + "translation": "Няправільная назва для палітыкі." + }, + { + "id": "model.access_policy.is_valid.revision.app_error", + "translation": "Няправільная версія палітыкі." + }, + { + "id": "model.access_policy.is_valid.rules.app_error", + "translation": "Правілы несапраўдныя." + }, + { + "id": "model.access_policy.is_valid.rules_imports.app_error", + "translation": "Палітыка павінна імпартаваць або вызначаць правілы." + }, + { + "id": "model.access_policy.is_valid.type.app_error", + "translation": "Няправільны тып палітыкі." + }, + { + "id": "model.access_policy.is_valid.version.app_error", + "translation": "Версія несапраўдная для гэтай палітыкі кантролю доступу." + }, + { + "id": "api.custom_profile_attributes.invalid_field_patch", + "translation": "няправільны патч поля атрыбута карыстальніцкага профілю" + }, + { + "id": "model.config.is_valid.elastic_search.empty_index_prefix.app_error", + "translation": "IndexPrefix не можа быць пустым, калі ўсталяваны GlobalSearchPrefix." + }, + { + "id": "model.config.is_valid.elastic_search.incorrect_search_prefix.app_error", + "translation": "GlobalSearchPrefix {{.GlobalSearchPrefix}} павінен быць прэфіксам IndexPrefix {{.IndexPrefix}}." + }, + { + "id": "app.custom_profile_attributes.delete_property_values_for_user.app_error", + "translation": "Немагчыма выдаліць значэнні атрыбутаў карыстальніцкага профілю для карыстальніка" + }, + { + "id": "app.custom_profile_attributes.validate_value.app_error", + "translation": "Не атрымалася праверыць значэнне ўласцівасці" + }, + { + "id": "ent.ldap.cpa_field_mapping.list_error", + "translation": "Не атрымалася атрымаць палі CPA" + }, + { + "id": "ent.ldap.update_cpa.empty_attribute", + "translation": "Пустое значэнне атрыбута LDAP" + }, + { + "id": "ent.saml.cpa_field_mapping.list_error", + "translation": "Не атрымалася атрымаць поле (палі) CPA" + }, + { + "id": "ent.saml.update_cpa.empty_attribute_statement", + "translation": "Інструкцыя атрыбута сцвярджэння SAML несапраўдная" + }, + { + "id": "web.incoming_webhook.parse_form.app_error", + "translation": "Не атрымалася прааналізаваць форму для вэбхука {{.hook_id}}." + }, + { + "id": "web.incoming_webhook.parse_multipart.app_error", + "translation": "Не атрымалася прааналізаваць шматкампанентную форму для вэбхука {{.hook_id}}." + }, + { + "id": "api.admin.add_certificate.app_error", + "translation": "Не атрымалася дадаць сертыфікат." + }, + { + "id": "api.admin.add_certificate.multiple_files.app_error", + "translation": "Занадта шмат файлаў для 'certificate' у запыце." + }, + { + "id": "api.admin.remove_certificate.app_error", + "translation": "Не атрымалася выдаліць сертыфікат." + }, + { + "id": "app.import.profile_image.open.app_error", + "translation": "Не атрымалася адкрыць файл выявы профілю: {{.FileName}}" + }, + { + "id": "app.import.profile_image.read_data.app_error", + "translation": "Не атрымалася прачытаць даныя выявы профілю." + }, + { + "id": "app.submit_interactive_dialog.decode_json_error", + "translation": "Памылка пры дэкадаванні JSON-адказу з інтэрактыўнага дыялогу." + }, + { + "id": "app.submit_interactive_dialog.read_body_error", + "translation": "Памылка пры чытанні цела адказу з інтэрактыўнага дыялогу." + }, + { + "id": "ent.ldap.do_login.invalid_id", + "translation": "Няправільны ідэнтыфікатар AD/LDAP" + }, + { + "id": "ent.ldap_groups.invalid_ldap_id", + "translation": "Няправільны ідэнтыфікатар AD/LDAP" + }, + { + "id": "api.license.load_metric.app_error", + "translation": "Не атрымалася падлічыць штомесячных актыўных карыстальнікаў." + }, + { + "id": "ent.saml.login.ldap_user_missing", + "translation": "На серверы AD/LDAP няма зарэгістраванага карыстальніка, які адпавядае карыстальніку SAML." + }, + { + "id": "license_error.feature_unavailable.specific", + "translation": "Функцыя {{.Feature}} недаступная для бягучай ліцэнзіі" + }, + { + "id": "model.config.is_valid.report_a_problem_link.invalid.app_error", + "translation": "Няправільная спасылка для паведамлення пра праблему. Павінна быць сапраўдным URL і пачынацца з http:// або https://." + }, + { + "id": "model.config.is_valid.report_a_problem_link.missing.app_error", + "translation": "Спасылка для паведамлення пра праблему абавязковая." + }, + { + "id": "model.config.is_valid.report_a_problem_mail.invalid.app_error", + "translation": "Няправільны адрас электроннай пошты для паведамлення пра праблему. Павінен быць сапраўдным адрасам электроннай пошты." + }, + { + "id": "model.config.is_valid.report_a_problem_mail.missing.app_error", + "translation": "Адрас электроннай пошты для паведамлення пра праблему абавязковы." + }, + { + "id": "app.custom_profile_attributes.property_field_is_synced.app_error", + "translation": "Немагчыма абнавіць значэнне для сінхранізаванага поля атрыбута карыстальніцкага профілю" + }, + { + "id": "api.access_control_policy.get_channels.limit.app_error", + "translation": "Ліміт на колькасць атрыманых каналаў няправільны." + }, + { + "id": "api.access_control_policy.get_fields.limit.app_error", + "translation": "Ліміт на колькасць атрыманых палёў няправільны." + }, + { + "id": "api.channel.add_user.to.channel.rejected", + "translation": "Карыстальнік не мае неабходных атрыбутаў для далучэння да канала." + }, + { + "id": "api.channel.update_channel.not_allowed.app_error", + "translation": "Каналы, на якія распаўсюджваецца палітыка, не могуць быць абноўлены." + }, + { + "id": "api.user.authorize_oauth_user.saml_hook_error.app_error", + "translation": "Адбылася памылка ў хуку OnSamlLogin. Звярніцеся да вашага сістэмнага адміністратара." + }, + { + "id": "app.channel.get.app_error", + "translation": "Не атрымалася атрымаць канал." + }, + { + "id": "app.group.user_not_found", + "translation": "Памылка абнаўлення групы. Карыстальнік з імем карыстальніка \"{{.Username}}\" не знойдзены." + }, + { + "id": "app.pap.assign_access_control_policy_to_channels.app_error", + "translation": "Немагчыма прызначыць палітыкі кантролю доступу каналам." + }, + { + "id": "app.pap.check_expression.app_error", + "translation": "Немагчыма праверыць выраз." + }, + { + "id": "app.pap.create_access_control_policy.app_error", + "translation": "Немагчыма стварыць палітыку кантролю доступу." + }, + { + "id": "app.pap.delete_access_control_policy.app_error", + "translation": "Немагчыма выдаліць палітыку кантролю доступу." + }, + { + "id": "app.pap.delete_policy.app_error", + "translation": "Немагчыма выдаліць палітыку кантролю доступу." + }, + { + "id": "app.pap.expression_to_visual_ast.app_error", + "translation": "Немагчыма згенераваць візуальны AST з выразу." + }, + { + "id": "app.pap.get_access_control_auto_complete.app_error", + "translation": "Немагчыма атрымаць аўтадапаўненне кантролю доступу." + }, + { + "id": "app.pap.get_all_access_control_policies.app_error", + "translation": "Немагчыма атрымаць палітыкі кантролю доступу." + }, + { + "id": "app.pap.get_channel_access_control_attributes.app_error", + "translation": "Немагчыма атрымаць атрыбуты для канала." + }, + { + "id": "app.pap.get_channel_members_to_remove.app_error", + "translation": "Немагчыма атрымаць членаў канала для выдалення." + }, + { + "id": "app.pap.get_policy.app_error", + "translation": "Немагчыма атрымаць палітыку кантролю доступу." + }, + { + "id": "app.pap.get_policy_attributes.app_error", + "translation": "Немагчыма атрымаць атрыбуты для палітыкі." + }, + { + "id": "app.pap.init.app_error", + "translation": "Немагчыма ініцыялізаваць сэрвіс кантролю доступу." + }, + { + "id": "app.pap.is_ready.app_error", + "translation": "Сэрвіс кантролю доступу не гатовы." + }, + { + "id": "app.pap.missing_attribute.app_error", + "translation": "У выразе адсутнічае атрыбут." + }, + { + "id": "app.pap.normalize_policy.app_error", + "translation": "Немагчыма нармалізаваць выраз палітыкі." + }, + { + "id": "app.pap.query_expression.app_error", + "translation": "Немагчыма запытаць выраз." + }, + { + "id": "app.pap.save_policy.app_error", + "translation": "Немагчыма захаваць палітыку кантролю доступу." + }, + { + "id": "app.pap.search_access_control_policies.app_error", + "translation": "Немагчыма знайсці палітыкі кантролю доступу." + }, + { + "id": "app.pap.unassign_access_control_policy_from_channels.app_error", + "translation": "Немагчыма адмяніць прызначэнне палітыкі кантролю доступу ад каналаў." + }, + { + "id": "app.pap.update_access_control_policy_active.app_error", + "translation": "Немагчыма змяніць актыўны статус палітыкі кантролю доступу." + }, + { + "id": "app.pdp.access_evaluation.app_error", + "translation": "Не атрымалася ацаніць палітыку кантролю доступу." + }, + { + "id": "ent.access_control.sync_job.app_error", + "translation": "Не атрымалася запусціць задачу сінхранізацыі кантролю доступу." + }, + { + "id": "model.access_policy.inherit.version.app_error", + "translation": "Немагчыма пераняць палітыку кантролю доступу." + }, + { + "id": "model.channel.is_valid.banner_info.background_color.invalid.app_error", + "translation": "Колер банера канала павінен быць сапраўдным шаснаццатковым колерам (напрыклад, #FF0000 або #F00)" + }, + { + "id": "app.group.create_syncable_memberships.error", + "translation": "Немагчыма стварыць членства груп, якія сінхранізуюцца." + }, + { + "id": "app.group.delete_invalid_syncable_memberships.error", + "translation": "Немагчыма выдаліць несапраўдныя членствы груп, якія сінхранізуюцца." + }, + { + "id": "model.config.is_valid.directory_whitespace.app_error", + "translation": "Выяўленыя прабелы ў пачатку або ў канцы для {{.Setting}}. Знойдзена \"{{.Value}}\"." + }, + { + "id": "app.import.validate_emoji_import_data.invalid_image_path.error", + "translation": "Поле выявы для імпарту эмодзі мае няправільны шлях: \"{{.Path}}\"" + }, + { + "id": "app.import.validate_post_import_data.attachment.error", + "translation": "Не атрымалася праверыць даныя ўкладання паведамлення." + }, + { + "id": "app.import.validate_reply_import_data.attachment.error", + "translation": "Не атрымалася праверыць даныя ўкладання адказу." + }, + { + "id": "app.import.validate_user_import_data.invalid_image_path.error", + "translation": "Няправільны шлях да выявы профілю карыстальніка: \"{{.Path}}\"" + }, + { + "id": "ent.access_control.job_data_conversion.app_error", + "translation": "Не атрымалася здабыць даныя з папярэдняй задачы." + }, + { + "id": "api.user.create_user.license_user_limits.exceeded", + "translation": "Немагчыма стварыць карыстальніка. Сервер перавышае максімальную колькасць ліцэнзаваных карыстальнікаў. Звярніцеся да адміністратара з паведамленнем: ERROR_LICENSED_USERS_LIMIT_EXCEEDED." + }, + { + "id": "app.acknowledgement.batch_save.app_error", + "translation": "Не атрымалася захаваць партыю аб'ектаў пацвярджэння" + }, + { + "id": "app.acknowledgement.delete.missing_post.app_error", + "translation": "Немагчыма выдаліць пацвярджэнне для адсутнага паведамлення" + }, + { + "id": "app.acknowledgement.save.missing_post.app_error", + "translation": "Немагчыма захаваць пацвярджэнне для адсутнага паведамлення" + }, + { + "id": "app.user.update_active.license_user_limit.exceeded", + "translation": "Немагчыма актываваць карыстальніка. Сервер перавышае максімальную колькасць ліцэнзаваных карыстальнікаў. ERROR_LICENSED_USERS_LIMIT_EXCEEDED." + }, + { + "id": "model.acknowledgement.is_valid.channel_id.app_error", + "translation": "Няправільны ідэнтыфікатар канала." + }, + { + "id": "api.ldap.invalid_test_type.app_error", + "translation": "Няправільны тып тэсту: {{.TestType}}" + }, + { + "id": "api.remote_cluster.import_not_allowed.app_error", + "translation": "Імпарт аддаленага кластара забаронены" + }, + { + "id": "api.user.get_users_not_in_abac_channel.access_control_unavailable.app_error", + "translation": "Сэрвіс кантролю доступу недаступны. Немагчыма адфільтраваць карыстальнікаў для канала з падтрымкай ABAC." + }, + { + "id": "ent.ldap.connection.test_failed", + "translation": "Тэст злучэння LDAP не ўдаўся. Сервер: {{.Server}}:{{.Port}}, ConnectionSecurity: {{.ConnectionType}}, PrivateKeyFilename: {{.PrivateKeyFilename}}, PublicCertificateFilename: {{.PublicCertFilename}}, BindUsername: {{.BindUsername}}. Памылка: {{.Error}}" + }, + { + "id": "model.config.is_valid.experimental_audit_settings.file_max_age_invalid", + "translation": "Максімальны ўзрост файлаў у канфігурацыі часопісаў аўдыту не можа быць адмоўным." + }, + { + "id": "model.config.is_valid.experimental_audit_settings.file_max_backups_invalid", + "translation": "Максімальная колькасць рэзервовых копій файлаў у канфігурацыі часопісаў аўдыту не можа быць адмоўнай." + }, + { + "id": "model.config.is_valid.experimental_audit_settings.file_max_queue_size_invalid", + "translation": "Максімальны памер чаргі файлаў у канфігурацыі часопісаў аўдыту павінен быць больш за нуль." + }, + { + "id": "model.config.is_valid.experimental_audit_settings.file_max_size_invalid", + "translation": "Максімальны памер файла ў канфігурацыі часопісаў аўдыту павінен быць больш за нуль." + }, + { + "id": "model.config.is_valid.experimental_audit_settings.file_name_empty", + "translation": "Калі рэгістрацыя файлаў аўдыту ўключана, неабходна пазначыць імя файла." + }, + { + "id": "model.config.is_valid.experimental_audit_settings.file_name_is_directory", + "translation": "Імя файла не можа быць каталогам." + }, + { + "id": "api.team.invite_guests.policy_enforced_channel.app_error", + "translation": "Немагчыма запрасіць гасцей у гэты канал, бо ён мае абмежаванні доступу на аснове атрыбутаў карыстальніка." + }, + { + "id": "api.command_share.service_inactive", + "translation": "Сэрвіс агульных каналаў уключаны, але не актыўны." + }, + { + "id": "api.channel.update_channel_member_roles.unset_user_scheme.app_error", + "translation": "Няправільнае абнаўленне ўдзельніка канала: удзельнік канала заўсёды мае схему карыстальніка." + }, + { + "id": "api.team.update_team_member_roles.unset_user_scheme.app_error", + "translation": "Няправільнае абнаўленне ўдзельніка каманды: удзельнік каманды заўсёды мае схему карыстальніка." + }, + { + "id": "api.upload.invalid_type_for_shared_channel.app_error", + "translation": "Не атрымалася загрузіць файл. Канал загрузкі не абагульнены з аддаленым серверам." + }, + { + "id": "app.import.validate_direct_post_import_data.thread_follower.error", + "translation": "Не атрымалася праверыць даныя падпісчыка гілкі прамога паведамлення." + }, + { + "id": "app.import.validate_post_import_data.thread_follower.error", + "translation": "Не атрымалася праверыць даныя падпісчыка гілкі паведамлення." + }, + { + "id": "app.import.validate_thread_follower_data.empty.error", + "translation": "Імпарт даных падпісчыкаў пусты." + }, + { + "id": "app.import.validate_thread_follower_data.user_missing.error", + "translation": "Не хапае абавязковай уласцівасці падпісчыка: карыстальнік." + }, + { + "id": "model.config.is_valid.content_flagging.common_reviewers_not_set.app_error", + "translation": "Агульныя рэцэнзенты або дадатковыя рэцэнзенты павінны быць устаноўлены, калі ўключана опцыя \"Агульныя рэцэнзенты для ўсіх каманд\"." + }, + { + "id": "model.config.is_valid.content_flagging.reasons_not_set.app_error", + "translation": "Прычыны для пазначэння не могуць быць пустымі." + }, + { + "id": "model.config.is_valid.content_flagging.team_reviewers_not_set.app_error", + "translation": "Рэцэнзенты каманды або дадатковыя рэцэнзенты павінны быць устаноўлены пры ўключэнні опцыі \"Уключана\" для каманды." + }, + { + "id": "model.config.is_valid.notification_settings.invalid_event", + "translation": "Зададзена няправільная падзея пазначэння." + }, + { + "id": "model.config.is_valid.notification_settings.invalid_target", + "translation": "Зададзена няправільная мэта падзеі пазначэння." + }, + { + "id": "model.config.is_valid.notification_settings.reviewer_flagged_notification_disabled", + "translation": "Апавяшчэнні аб новых пазначаных паведамленнях не могуць быць адключаны для рэцэнзентаў." + }, + { + "id": "app.cloud.preview_modal_bucket_url_not_configured", + "translation": "URL бакета папярэдняга прагляду не наладжаны" + }, + { + "id": "app.cloud.preview_modal_data_fetch_error", + "translation": "Не атрымалася атрымаць даныя модальнага акна папярэдняга прагляду" + }, + { + "id": "app.cloud.preview_modal_data_parse_error", + "translation": "Не атрымалася прааналізаваць даныя модальнага акна папярэдняга прагляду" + }, + { + "id": "api.content_flagging.error.disabled", + "translation": "Функцыя пазначэння кантэнту адключана." + }, + { + "id": "api.content_flagging.error.license", + "translation": "Ваша ліцэнзія не падтрымлівае пазначэнне кантэнту." + }, + { + "id": "model.access_policy.inherit.already_imported.app_error", + "translation": "Бацькоўскі элемент ужо імпартаваны." + }, + { + "id": "model.config.is_valid.experimental_view_archived_channels.app_error", + "translation": "Схаванне архіваваных каналаў больш не падтрымліваецца. Замест гэтага зрабіце гэтыя каналы прыватнымі і выдаліце ўдзельнікаў." + }, + { + "id": "app.access_control.insufficient_permissions", + "translation": "У вас няма дазволу кіраваць гэтай палітыкай кантролю доступу." + }, + { + "id": "app.pap.access_control.channel_group_constrained", + "translation": "Канал абмежаваны для груп і да яго не могуць быць прыменены палітыкі кантролю доступу." + }, + { + "id": "app.pap.access_control.channel_not_private", + "translation": "Палітыкі кантролю доступу могуць быць прыменены толькі да прыватных каналаў." + }, + { + "id": "app.pap.access_control.channel_shared", + "translation": "Да агульных каналаў не могуць быць прыменены палітыкі кантролю доступу." + }, + { + "id": "app.pap.access_control.insufficient_channel_permissions", + "translation": "У вас няма дазволу кіраваць доступам для гэтага канала." + }, + { + "id": "app.pap.access_control.insufficient_permissions", + "translation": "У вас няма дазволу кіраваць гэтай палітыкай кантролю доступу." + }, + { + "id": "app.custom_profile_attributes.property_field_is_managed.app_error", + "translation": "Немагчыма абнавіць значэнне для поля карыстальніцкага атрыбута профілю, кіраванага адміністратарам" + }, + { + "id": "model.config.is_valid.client_side_cert_enable.app_error", + "translation": "Аўтэнтыфікацыя на аснове сертыфікатаў была выдалена. Каб працягнуць, адключыце ClientSideCertEnable." + }, + { + "id": "app.lookup_interactive_dialog.decode_json_error", + "translation": "Памылка пры дэкадаванні JSON-адказу ад інтэрактыўнага пошуку ў дыялогавым акне." + }, + { + "id": "app.lookup_interactive_dialog.json_error", + "translation": "Памылка пры апрацоўцы JSON-адказу ад інтэрактыўнага пошуку ў дыялогавым акне." + }, + { + "id": "app.lookup_interactive_dialog.read_body_error", + "translation": "Памылка пры чытанні цела адказу ад інтэрактыўнага пошуку ў дыялогавым акне." + }, + { + "id": "ent.elasticsearch.create_processor.bulk_processor_create_failed", + "translation": "Не атрымалася стварыць масавы працэсар Elasticsearch" + }, + { + "id": "ent.elasticsearch.create_processor.sync_bulk_processor_create_failed", + "translation": "Не атрымалася стварыць працэсар масавай сінхранізацыі Elasticsearch" + }, + { + "id": "api.user.check_user_password.invalid_hash.app_error", + "translation": "Фармат хешаванай паролі няправільны і не можа быць прааналізаваны." + }, + { + "id": "app.user.check_user_password.failed_migration", + "translation": "Не атрымалася міграваць пароль карыстальніка да найноўшага метаду хешавання." + }, + { + "id": "app.user.check_user_password.failed_update", + "translation": "Не атрымалася абнавіць пароль карыстальніка." + }, + { + "id": "app.job.get_existing_jobs.error", + "translation": "Немагчыма атрымаць існуючыя задачы." + }, + { + "id": "app.submit_interactive_dialog.invalid_response", + "translation": "Сустрэты няправільны адказ ад адпраўкі інтэрактыўнага дыялогу." + }, + { + "id": "api.channel.patch_update_channel.restricted_dm.app_error", + "translation": "Нельга абнавіць абмежаваны канал прамых паведамленняў." + }, + { + "id": "api.command.execute_command.restricted_dm.error", + "translation": "Нельга пісаць у абмежаваным DM" + }, + { + "id": "api.content_flagging.error.comment_required", + "translation": "Калі ласка, дадайце каментар, які тлумачыць, чаму вы пазначаеце гэтае паведамленне." + }, + { + "id": "api.content_flagging.error.comment_too_long", + "translation": "Прычына пазначэння паведамлення не можа перавышаць {{.MaxLength}} сімвалаў." + }, + { + "id": "api.content_flagging.error.not_available_on_team", + "translation": "Функцыя пазначэння кантэнту не ўключана ў гэтай камандзе." + }, + { + "id": "api.content_flagging.error.post_not_in_progress", + "translation": "Пазначанае паведамленне павінна мець статус \"чакае\" або \"прызначана\", каб яго можна было захаваць або выдаліць." + }, + { + "id": "api.content_flagging.error.reason_invalid", + "translation": "Указана невядомая прычына для пазначэння паведамлення." + }, + { + "id": "api.content_flagging.error.reason_required", + "translation": "Калі ласка, выберыце прычыну для пазначэння гэтага паведамлення." + }, + { + "id": "api.draft.create_draft.can_not_draft_to_restricted_dm.error", + "translation": "Нельга захаваць чарнавік у абмежаваны канал прамых паведамленняў" + }, + { + "id": "api.file.upload_file.get_channel.app_error", + "translation": "Нельга загрузіць файл у абмежаваны DM" + }, + { + "id": "api.file.upload_file.restricted_dm.error", + "translation": "Нельга загрузіць файл у абмежаваны DM" + }, + { + "id": "api.post.create_post.can_not_post_in_restricted_dm.error", + "translation": "Нельга публікаваць у абмежаваным канале прамых паведамленняў." + }, + { + "id": "api.post.delete_post.can_not_delete_from_restricted_dm.error", + "translation": "Нельга выдаліць паведамленне ў абмежаваным канале прамых паведамленняў." + }, + { + "id": "api.post.patch_post.can_not_update_post_in_restricted_dm.error", + "translation": "Нельга абнавіць паведамленне ў абмежаваным канале прамых паведамленняў." + }, + { + "id": "api.post.update_post.can_not_update_post_in_restricted_dm.error", + "translation": "Нельга абнавіць паведамленне ў абмежаваным канале прамых паведамленняў." + }, + { + "id": "api.reaction.delete.restricted_dm.error", + "translation": "Нельга рэагаваць у абмежаваным DM" + }, + { + "id": "api.reaction.save.restricted_dm.error", + "translation": "Нельга рэагаваць у абмежаваным DM" + }, + { + "id": "app.channel.get_common_teams.app_error", + "translation": "Памылка атрымання агульных каманд для канала" + }, + { + "id": "app.content_flagging.can_flag_post.in_progress", + "translation": "Нельга пазначыць гэтае паведамленне, бо яно ўжо пазначана." + }, + { + "id": "app.content_flagging.can_flag_post.removed", + "translation": "Нельга пазначыць гэтае паведамленне, бо яно было выдалена ў папярэднім запыце на пазначэнне." + }, + { + "id": "app.content_flagging.can_flag_post.retained", + "translation": "Нельга пазначыць гэтае паведамленне, бо яно было захавана ў папярэднім запыце на пазначэнне." + }, + { + "id": "app.content_flagging.can_flag_post.unknown", + "translation": "Нельга пазначыць гэтае паведамленне, бо яно мае невядомы статус." + }, + { + "id": "app.content_flagging.create_property_values.app_error", + "translation": "Не атрымалася захаваць значэнні ўласцівасцей для пазначанага паведамлення." + }, + { + "id": "app.content_flagging.flag_post.marshal_comment.app_error", + "translation": "Не атрымалася сабраць каментар карыстальніка, які пазначыў паведамленне" + }, + { + "id": "app.content_flagging.flag_post.marshal_reason.app_error", + "translation": "Не атрымалася сабраць прычыну ад карыстальніка, які пазначыў паведамленне" + }, + { + "id": "app.content_flagging.flag_post_confirmation.message", + "translation": "Паведамленне ад @{{.username}} было пазначана для праверкі. Вы атрымаеце апавяшчэнне пасля праверкі яго Кантэнт-рэцэнзентам. " + }, + { + "id": "app.content_flagging.get_group.error", + "translation": "Не атрымалася атрымаць бота для пазначэння кантэнту." + }, + { + "id": "app.content_flagging.get_status_property.app_error", + "translation": "Не атрымалася атрымаць поле ўласцівасці \"Статус\"." + }, + { + "id": "app.content_flagging.get_users_in_team.app_error", + "translation": "Не атрымалася знайсці рэцэнзентаў у камандзе." + }, + { + "id": "app.content_flagging.keep_flag_post.marshal_comment.app_error", + "translation": "Не атрымалася сабраць каментар рэцэнзента" + }, + { + "id": "app.content_flagging.keep_post.status_update.app_error", + "translation": "Не атрымалася абнавіць статус пазначанага паведамлення пры аднаўленні пазначанага паведамлення " + }, + { + "id": "app.content_flagging.keep_post.undelete.app_error", + "translation": "Не атрымалася абнавіць паведамленне ў базе даных пры спробе адмяніць выдаленне пазначанага паведамлення і звязаных з ім даных." + }, + { + "id": "app.content_flagging.marshal_property_values.app_error", + "translation": "Не атрымалася сабраць значэнні ўласцівасцей пазначэння кантэнту для адпраўкі ў падзеі WebSocket." + }, + { + "id": "app.content_flagging.no_status_property.app_error", + "translation": "Нельга атрымаць пазначанае паведамленне, бо яно не пазначана." + }, + { + "id": "app.content_flagging.permanently_delete.app_error", + "translation": "Не атрымалася перазапісаць паведамленне ачышчаным паведамленнем пры пастаянным выдаленні пазначанага паведамлення." + }, + { + "id": "app.content_flagging.permanently_delete.marshal_comment.app_error", + "translation": "Не атрымалася сабраць каментар рэцэнзента" + }, + { + "id": "app.content_flagging.permanently_delete.update_property_value.app_error", + "translation": "Не атрымалася абнавіць статус пазначанага паведамлення пры пастаянным выдаленні пазначанага паведамлення." + }, + { + "id": "app.content_flagging.search_status_property.app_error", + "translation": "Не атрымалася знайсці значэнні ўласцівасці для маркіраванага паведамлення." + }, + { + "id": "app.post_priority.delete_for_post.app_error", + "translation": "Не атрымалася назаўжды выдаліць даныя прыярытэту паведамлення з базы дадзеных." + }, + { + "id": "app.save_scheduled_post.restricted_dm.error", + "translation": "Нельга запланаваць паведамленне ў абмежаваным асабістым чаце" + }, + { + "id": "app.system.content_review_bot.bot_displayname", + "translation": "Агляд змесціва" + }, + { + "id": "app.upload.create.cannot_upload_to_restricted_dm.error", + "translation": "Нельга загрузіць файл у абмежаваны асабісты чат." + }, + { + "id": "api.encoding_error", + "translation": "Памылка кадавання JSON для адказу API" + }, + { + "id": "app.content_flagging.get_reviewer_settings.app_error", + "translation": "Не атрымалася атрымаць налады аглядальніка змесціва з базы дадзеных" + }, + { + "id": "app.content_flagging.save_reviewer_settings.app_error", + "translation": "Не атрымалася захаваць налады аглядальніка змесціва ў базе дадзеных." + }, + { + "id": "model.config.is_valid.autotranslation.libretranslate.url.app_error", + "translation": "URL LibreTranslate павінен быць сапраўдным URL і пачынацца з http:// або https://." + }, + { + "id": "model.config.is_valid.autotranslation.provider.app_error", + "translation": "Несапраўдны пастаўшчык аўтаматычнага перакладу, не можа быць пустым." + }, + { + "id": "model.config.is_valid.autotranslation.provider.unsupported.app_error", + "translation": "Непадтрымоўваны пастаўшчык аўтаматычнага перакладу." + }, + { + "id": "model.config.is_valid.autotranslation.timeouts.fetch.app_error", + "translation": "Несапраўдны час чакання атрымання для налад аўтаматычнага перакладу. Павінна быць станоўчым лікам." + }, + { + "id": "model.config.is_valid.autotranslation.timeouts.new_post.app_error", + "translation": "Несапраўдны час чакання новага паведамлення для налад аўтаматычнага перакладу. Павінна быць станоўчым лікам." + }, + { + "id": "model.config.is_valid.autotranslation.timeouts.notification.app_error", + "translation": "Несапраўдны час чакання апавяшчэння для налад аўтаматычнага перакладу. Павінна быць станоўчым лікам." + }, + { + "id": "api.channel.get_channel.flagged_post_mismatch.app_error", + "translation": "Ідэнтыфікатар канала не супадае з ідэнтыфікатарам маркіраванага паведамлення." + }, + { + "id": "api.channel.patch_channel_moderations_for_channel.restricted_permission.app_error", + "translation": "Нельга дадаць дазвол, які абмежаваны схемай дазволаў каманды або сістэмы." + }, + { + "id": "api.content_flagging.error.user_not_reviewer", + "translation": "Карыстальнік не з'яўляецца аглядальнікам змесціва." + }, + { + "id": "api.team.get_team.flagged_post_mismatch.app_error", + "translation": "Ідэнтыфікатар каманды не супадае з ідэнтыфікатарам каманды маркіраванага паведамлення." + }, + { + "id": "app.content_flagging.assign_reviewer.no_reviewer_field.app_error", + "translation": "Поле ўласцівасці ID аглядальніка не знойдзена." + }, + { + "id": "app.content_flagging.assign_reviewer.update_status_property_value.app_error", + "translation": "Не атрымалася абнавіць значэнне ўласцівасці Статус пры прызначэнні аглядальніка." + }, + { + "id": "app.content_flagging.assign_reviewer.upsert_property_value.app_error", + "translation": "Не атрымалася прызначыць аглядальніка для маркіраванага паведамлення." + }, + { + "id": "app.content_flagging.missing_flagged_post_id_field.app_error", + "translation": "Немагчыма знайсці поле ўласцівасці ID маркіраванага паведамлення." + }, + { + "id": "app.content_flagging.missing_reporting_user_id_field.app_error", + "translation": "Немагчыма знайсці поле карыстальніка, які паведамляе." + }, + { + "id": "app.content_flagging.missing_reporting_user_id_property_value.app_error", + "translation": "Немагчыма знайсці значэнне для поля карыстальніка, які паведамляе." + }, + { + "id": "app.content_flagging.search_common_reviewers.app_error", + "translation": "Не атрымалася знайсці тэрмін у Агульных аглядальніках." + }, + { + "id": "app.content_flagging.search_reviewer_posts.app_error", + "translation": "Не атрымалася знайсці паведамленні аглядальнікаў для маркіраванага паведамлення." + }, + { + "id": "app.content_flagging.search_sysadmin_reviewers.app_error", + "translation": "Не атрымалася знайсці тэрмін пошуку ў Аглядальніках сістэмных адміністратараў." + }, + { + "id": "app.content_flagging.search_team_admin_reviewers.app_error", + "translation": "Не атрымалася знайсці тэрмін пошуку ў Аглядальніках камандных адміністратараў." + }, + { + "id": "app.content_flagging.search_team_reviewers.app_error", + "translation": "Не атрымалася знайсці тэрмін пошуку ў Камандных аглядальніках." + }, + { + "id": "app.content_flagging.get_thread_replies.app_error", + "translation": "Не атрымалася атрымаць адказы з гілкі для маркіраванага паведамлення." + }, + { + "id": "app.content_flagging.missing_manage_by_field.app_error", + "translation": "Не атрымалася знайсці поле ўласцівасці Кіруецца." + }, + { + "id": "app.content_flagging.missing_status_field.app_error", + "translation": "Не атрымалася знайсці поле ўласцівасці Статус." + }, + { + "id": "app.content_flagging.set_thread_replies_properties.app_error", + "translation": "Не атрымалася задаць значэнні ўласцівасці ў адказах гілкі для маркіраванага паведамлення." + }, + { + "id": "api.user.delete_channel.not_enabled.for_admin.app_error", + "translation": "Функцыя назаўждыга выдалення канала не ўключана. ServiceSettings.EnableAPIChannelDeletion павінна быць устаноўлена ў true, каб выкарыстоўваць гэту каманду. Глядзіце https://mattermost.com/pl/environment-configuration-settings для атрымання дадатковай інфармацыі" + }, + { + "id": "api.user.delete_team.not_enabled.for_admin.app_error", + "translation": "Функцыя назаўждыга выдалення каманды не ўключана. ServiceSettings.EnableAPITeamDeletion павінна быць устаноўлена ў true, каб выкарыстоўваць гэту каманду. Глядзіце https://mattermost.com/pl/environment-configuration-settings для атрымання дадатковай інфармацыі." + }, + { + "id": "api.user.delete_user.not_enabled.for_admin.app_error", + "translation": "Функцыя назаўждыга выдалення карыстальніка не ўключана. ServiceSettings.EnableAPIUserDeletion павінна быць устаноўлена ў true, каб выкарыстоўваць гэту каманду. Глядзіце https://mattermost.com/pl/environment-configuration-settings для атрымання дадатковай інфармацыі." + }, + { + "id": "app.agents.get_agents.app_error", + "translation": "Не атрымалася атрымаць агентаў." + }, + { + "id": "app.agents.get_agents.bridge_call_failed", + "translation": "Злучэнне моста не атрымалася." + }, + { + "id": "app.agents.get_services.app_error", + "translation": "Не атрымалася атрымаць LLM-службы." + }, + { + "id": "app.agents.get_services.bridge_call_failed", + "translation": "Злучэнне моста не атрымалася." + }, + { + "id": "api.command.move_command.creator_no_permission.app_error", + "translation": "Няма дазволу на перамяшчэнне каманды" + }, + { + "id": "api.oauth.allow_oauth.pkce_required_public.app_error", + "translation": "PKCE (Proof Key for Code Exchange) патрабуецца для публічных OAuth-кліентаў, якія выкарыстоўваюць паток кода аўтарызацыі." + }, + { + "id": "api.oauth.authorization_server_metadata.disabled.app_error", + "translation": "Пастаўшчык OAuth-паслуг адключаны." + }, + { + "id": "api.oauth.authorization_server_metadata.invalid_url.app_error", + "translation": "Несапраўдная канфігурацыя URL для метаданых сервера аўтарызацыі." + }, + { + "id": "api.oauth.authorization_server_metadata.site_url_required.app_error", + "translation": "URL сайта павінен быць наладжаны для прадастаўлення метаданых сервера аўтарызацыі." + }, + { + "id": "api.oauth.get_access_token.resource_mismatch.app_error", + "translation": "Несупадзенне параметраў рэсурсу паміж запытамі аўтарызацыі і токена." + }, + { + "id": "api.oauth.regenerate_secret.public_client.app_error", + "translation": "invalid_request: Немагчыма аднавіць сакрэт для публічных OAuth-кліентаў." + }, + { + "id": "api.post.fill_in_post_props.invalid_ai_generated_user.app_error", + "translation": "Карыстальнік, створаны ШІ, павінен быць стваральнікам паведамлення або ботам." + }, + { + "id": "app.custom_profile_attributes.patch_field.app_error", + "translation": "Немагчыма абнавіць поле карыстальніцкага атрыбута профілю" + }, + { + "id": "app.post.rewrite.agent_call_failed", + "translation": "Не атрымалася выклікаць ШІ-агента." + }, + { + "id": "app.post.rewrite.empty_response", + "translation": "Пусты адказ ад ШІ." + }, + { + "id": "app.post.rewrite.invalid_action", + "translation": "Несапраўднае дзеянне перапісвання." + }, + { + "id": "app.post.rewrite.parse_response_failed", + "translation": "Не атрымалася прааналізаваць адказ перапісвання ад ШІ." + }, + { + "id": "ent.push_proxy.delete.app_error", + "translation": "Не атрымалася выдаліць токен аўтарызацыі push proxy." + }, + { + "id": "ent.push_proxy.generate.bad_status.app_error", + "translation": "Не атрымалася атрымаць токен аўтарызацыі з сервера push proxy." + }, + { + "id": "ent.push_proxy.generate.create_request.app_error", + "translation": "Не атрымалася стварыць HTTP-запыт для аўтарызацыі push proxy." + }, + { + "id": "ent.push_proxy.generate.empty_token.app_error", + "translation": "Push proxy вярнуў пусты токен аўтарызацыі." + }, + { + "id": "ent.push_proxy.generate.encrypt.app_error", + "translation": "Не атрымалася зашыфраваць payload для аўтарызацыі push proxy." + }, + { + "id": "ent.push_proxy.generate.http_request.app_error", + "translation": "Не атрымалася адправіць HTTP-запыт да push proxy." + }, + { + "id": "ent.push_proxy.generate.marshal.app_error", + "translation": "Не атрымалася сабраць payload для аўтарызацыі push proxy." + }, + { + "id": "ent.push_proxy.generate.marshal_request.app_error", + "translation": "Не атрымалася сабраць цела запыту для аўтарызацыі push proxy." + }, + { + "id": "ent.push_proxy.generate.no_key.app_error", + "translation": "Няма даступнага ключа шыфравання для аўтарызацыі push proxy." + }, + { + "id": "ent.push_proxy.generate.parse_response.app_error", + "translation": "Не атрымалася прааналізаваць адказ з кропкі доступу аўтарызацыі push proxy." + }, + { + "id": "ent.push_proxy.generate.save_system.app_error", + "translation": "Не атрымалася захаваць токен аўтарызацыі." + }, + { + "id": "ent.push_proxy.worker.interface_nil.app_error", + "translation": "Push proxy інтэрфейс з'яўляецца nil." + }, + { + "id": "model.authorize.is_valid.code_challenge.app_error", + "translation": "Выклік кода патрабуецца пры выкарыстанні PKCE." + }, + { + "id": "model.authorize.is_valid.code_challenge.format.app_error", + "translation": "Выклік кода павінен быць закодаваны base64url (з выкарыстаннем сімвалаў A-Z, a-z, 0-9, -, _)." + }, + { + "id": "model.authorize.is_valid.code_challenge.length.app_error", + "translation": "Выклік кода павінен мець даўжыню ад 43 да 128 сімвалаў." + }, + { + "id": "model.authorize.is_valid.code_challenge_method.app_error", + "translation": "Метад выкліку кода патрабуецца пры выкарыстанні PKCE." + }, + { + "id": "model.authorize.is_valid.code_challenge_method.unsupported.app_error", + "translation": "Падтрымліваецца толькі метад выкліку кода \"S256\"." + }, + { + "id": "model.authorize.is_valid.resource.has_fragment.app_error", + "translation": "Параметр рэсурсу не можа ўтрымліваць кампанент фрагмента." + }, + { + "id": "model.authorize.is_valid.resource.invalid_uri.app_error", + "translation": "Параметр рэсурсу павінен быць сапраўдным URI." + }, + { + "id": "model.authorize.is_valid.resource.length.app_error", + "translation": "Параметр рэсурсу не можа перавышаць 512 сімвалаў." + }, + { + "id": "model.authorize.is_valid.resource.not_absolute.app_error", + "translation": "Параметр рэсурсу павінен быць абсалютным URI." + }, + { + "id": "model.authorize.validate_pkce.not_used_in_auth.app_error", + "translation": "Прадастаўлены праверачнік кода PKCE, але PKCE не выкарыстоўваўся падчас аўтарызацыі." + }, + { + "id": "model.authorize.validate_pkce.public_client_required.app_error", + "translation": "PKCE (Proof Key for Code Exchange) патрабуецца для публічных кліентаў." + }, + { + "id": "model.authorize.validate_pkce.verification_failed.app_error", + "translation": "Праверачнік кода PKCE не супадае з выклікам кода." + }, + { + "id": "model.authorize.validate_pkce.verifier_required.app_error", + "translation": "Праверачнік кода PKCE патрабуецца пры выкарыстанні PKCE." + }, + { + "id": "model.dcr.is_valid.client_name.app_error", + "translation": "Назва кліента павінна быць не больш за 64 сімвалаў." + }, + { + "id": "model.dcr.is_valid.client_uri_format.app_error", + "translation": "Несапраўдны фармат URI кліента." + }, + { + "id": "model.dcr.is_valid.client_uri_length.app_error", + "translation": "URI кліента павінен быць менш за 256 сімвалаў." + }, + { + "id": "model.dcr.is_valid.redirect_uri_format.app_error", + "translation": "Несапраўдны фармат URI пераадрасацыі." + }, + { + "id": "model.dcr.is_valid.redirect_uris.app_error", + "translation": "Патрабуецца хаця б адзін URI пераадрасацыі." + }, + { + "id": "model.dcr.is_valid.unsupported_auth_method.app_error", + "translation": "Падчас перадачы быў выкарыстаны непадтрымоўваны token_endpoint_auth_method." + }, + { + "id": "model.oauth.validate_grant.credentials.app_error", + "translation": "Несапраўдныя даныя для аўтарызацыі кліента." + }, + { + "id": "model.oauth.validate_grant.pkce_required.app_error", + "translation": "PKCE (Proof Key for Code Exchange) патрабуецца для публічных кліентаў." + }, + { + "id": "model.oauth.validate_grant.public_client_refresh_token.app_error", + "translation": "Публічныя кліенты не могуць выкарыстоўваць тып прадастаўлення токена абнаўлення." + }, + { + "id": "model.oauth.validate_grant.public_client_secret.app_error", + "translation": "Публічныя кліенты не павінны прадастаўляць сакрэт кліента." + }, + { + "id": "api.file.get_file.invalid_flagged_post.app_error", + "translation": "Указаны няправільны ідэнтыфікатар маркіраванага паведамлення." + }, + { + "id": "api.file.get_file_info.app_error", + "translation": "Не атрымалася атрымаць інфармацыю аб файле." + }, + { + "id": "api.post.get_posts_for_reporting.channel_not_found", + "translation": "Канал не знойдзены" + }, + { + "id": "api.team.invite_guests_to_channels.guest_magic_link_disabled.error", + "translation": "Магічны спасылкі для гасцей не ўключаны" + }, + { + "id": "api.templates.guest_magic_link_body.footer.info", + "translation": "Гэта электроннае пісьмо адпраўлена вам, таму што хтосьці запытаў спасылку для ўваходу ў Mattermost. Калі вы не запытвалі гэтага, вы можаце бяспечна праігнараваць гэтае пісьмо." + }, + { + "id": "api.templates.guest_magic_link_body.footer.title", + "translation": "Не запытвалі гэтае пісьмо?" + }, + { + "id": "api.templates.guest_magic_link_body.subtitle", + "translation": "Націсніце кнопку ніжэй, каб увайсці ў свой уліковы запіс. Памятайце, што не трэба дзяліцца гэтай спасылкай з кімсьці іншым." + }, + { + "id": "api.templates.guest_magic_link_body.title", + "translation": "Увайсці ў Mattermost" + }, + { + "id": "api.templates.guest_magic_link_subject", + "translation": "Увайсці ў {{ .SiteName }}" + }, + { + "id": "api.user.guest_magic_link.expired_token.app_error", + "translation": "Гэтая спасылка-запрашэнне скончылася." + }, + { + "id": "api.user.guest_magic_link.invalid_token.app_error", + "translation": "Няслушны спасылкі запрашэння." + }, + { + "id": "api.user.guest_magic_link.invalid_token_type.app_error", + "translation": "Няслушны тып спасылкі запрашэння." + }, + { + "id": "api.user.guest_magic_link.missing_token.app_error", + "translation": "Брак токена запрашэння." + }, + { + "id": "api.user.guest_magic_link.username_generation_failed.app_error", + "translation": "Немагчыма стварыць унікальнае імя карыстальніка. Звярніцеся да вашага сістэмнага адміністратара." + }, + { + "id": "api.user.login.guest_magic_link.disabled.error", + "translation": "Уваход з дапамогай чароўнай спасылкі адключаны." + }, + { + "id": "api.user.promote_guest_to_user.magic_link_enabled.app_error", + "translation": "Немагчыма пераўтварыць госця ў звычайнага карыстальніка, бо госць выкарыстоўвае аўтэнтыфікацыю па чароўнай спасылцы." + }, + { + "id": "api.user.send_password_reset.guest_magic_link.app_error", + "translation": "Немагчыма скінуць пароль для ўліковых запісаў госцяў з чароўнай спасылкай." + }, + { + "id": "app.pap.update_access_control_policies_active.app_error", + "translation": "Немагчыма абнавіць актыўны статус палітык кантролю доступу." + }, + { + "id": "app.post.get_posts_for_reporting.app_error", + "translation": "Немагчыма атрымаць паведамленні для справаздачнасці." + }, + { + "id": "app.post.get_posts_for_reporting.invalid_input_error", + "translation": "Немагчыма атрымаць паведамленні для справаздачнасці. Няслушныя ўваходныя дадзеныя." + }, + { + "id": "app.post.get_posts_for_reporting.license_error", + "translation": "Ваша ліцэнзія не падтрымлівае справаздачнасць па паведамленнях." + }, + { + "id": "model.post.decode_cursor.invalid_base64", + "translation": "Няслушны фармат курсора: нельга дэкадаваць base64." + }, + { + "id": "model.post.decode_cursor.invalid_exclude_system_posts", + "translation": "Няслушны фармат курсора: exclude_system_posts павінен быць булевым." + }, + { + "id": "model.post.decode_cursor.invalid_format", + "translation": "Няслушны фармат курсора: чакалася 8 частак." + }, + { + "id": "model.post.decode_cursor.invalid_include_deleted", + "translation": "Няслушны фармат курсора: include_deleted павінен быць булевым." + }, + { + "id": "model.post.decode_cursor.invalid_timestamp", + "translation": "Няслушны фармат курсора: timestamp павінен быць цэлым лікам." + }, + { + "id": "model.post.decode_cursor.invalid_version", + "translation": "Няслушны фармат курсора: версія павінна быць цэлым лікам." + }, + { + "id": "model.post.decode_cursor.unsupported_version", + "translation": "Непадтрымоўваная версія курсора." + }, + { + "id": "model.post.query_params.invalid_channel_id", + "translation": "Няслушны ідэнтыфікатар канала." + }, + { + "id": "model.post.query_params.invalid_cursor_id", + "translation": "Няслушны ідэнтыфікатар курсора." + }, + { + "id": "model.post.query_params.invalid_sort_direction", + "translation": "Няслушны кірунак сартавання." + }, + { + "id": "model.post.query_params.invalid_time_field", + "translation": "Няслушнае поле часу." + }, + { + "id": "store.sql_autotranslation.channel_not_found", + "translation": "Канал не знойдзены." + }, + { + "id": "store.sql_autotranslation.get.app_error", + "translation": "Немагчыма атрымаць пераклад." + }, + { + "id": "store.sql_autotranslation.get_active_languages.app_error", + "translation": "Немагчыма атрымаць актыўныя мовы." + }, + { + "id": "store.sql_autotranslation.get_channel_enabled.app_error", + "translation": "Немагчыма атрымаць статус уключэння аўтаперакладу канала." + }, + { + "id": "store.sql_autotranslation.get_user_enabled.app_error", + "translation": "Немагчыма атрымаць статус уключэння аўтаперакладу карыстальніка." + }, + { + "id": "store.sql_autotranslation.get_user_language.app_error", + "translation": "Немагчыма атрымаць мову карыстальніка." + }, + { + "id": "store.sql_autotranslation.is_channel_enabled.app_error", + "translation": "Немагчыма атрымаць статус уключэння аўтаперакладу канала." + }, + { + "id": "store.sql_autotranslation.member_not_found", + "translation": "Удзельнік не знойдзены." + }, + { + "id": "store.sql_autotranslation.meta_json.app_error", + "translation": "Не атрымалася прааналізаваць JSON метададзеных перакладу." + }, + { + "id": "store.sql_autotranslation.query_build_error", + "translation": "Не атрымалася стварыць запыт." + }, + { + "id": "store.sql_autotranslation.save.app_error", + "translation": "Немагчыма захаваць пераклад." + }, + { + "id": "store.sql_autotranslation.save.invalid_translation", + "translation": "Праверка перакладу не ўдалася. Аб'ект перакладу няслушны." + }, + { + "id": "store.sql_autotranslation.save.meta_json.app_error", + "translation": "Не атрымалася серыялізаваць метададзеныя перакладу ў JSON." + }, + { + "id": "store.sql_autotranslation.set_channel_enabled.app_error", + "translation": "Немагчыма ўсталяваць статус уключэння аўтаперакладу канала." + }, + { + "id": "store.sql_autotranslation.set_user_enabled.app_error", + "translation": "Немагчыма ўсталяваць статус уключэння аўтаперакладу карыстальніка." + }, + { + "id": "api.post.burn_post.user_not_in_channel.app_error", + "translation": "У вас няма дазволу на выдаленне гэтага паведамлення. Вы павінны быць удзельнікам канала." + }, + { + "id": "api.post.create_post.burn_on_read.app_error", + "translation": "Адбылася памылка пры стварэнні паведамлення з самазнішчэннем пасля прачытання." + }, + { + "id": "api.post.fill_in_post_props.burn_on_read.config.app_error", + "translation": "Паведамленні з самазнішчэннем пасля прачытання не ўключаны. Уключыце сцяжок функцыі і наладу службы." + }, + { + "id": "api.post.fill_in_post_props.burn_on_read.license.app_error", + "translation": "Паведамленні з самазнішчэннем пасля прачытання патрабуюць ліцэнзіі Enterprise Advanced." + }, + { + "id": "api.post.get_link_metadata_for_permalink.burn_on_read.app_error", + "translation": "Немагчыма атрымаць метададзеныя спасылкі для пастаяннай спасылкі на паведамленне з самазнішчэннем." + }, + { + "id": "api.post.patch_post.can_not_update_burn_on_read_post.error", + "translation": "Паведамленні з самазнішчэннем не могуць быць абноўлены." + }, + { + "id": "api.post.reveal_post.cannot_reveal_own_post.app_error", + "translation": "Вы не можаце раскрыць сваё ўласнае паведамленне з самазнішчэннем." + }, + { + "id": "api.post.reveal_post.disabled.app_error", + "translation": "Функцыя самазнішчэння паведамленняў не ўключана або патрабуе ліцэнзіі Enterprise Advanced." + }, + { + "id": "api.post.reveal_post.user_not_in_channel.app_error", + "translation": "У вас няма дазволу на раскрыццё гэтага паведамлення. Вы павінны быць удзельнікам канала." + }, + { + "id": "api.post.update_post.burn_on_read.app_error", + "translation": "Паведамленні з самазнішчэннем не могуць быць адрэдагаваныя." + }, + { + "id": "api.reaction.save.burn_on_read.app_error", + "translation": "Вы не можаце рэагаваць на паведамленне з самазнішчэннем." + }, + { + "id": "api.user.login_by_intune.account_locked.app_error", + "translation": "Ваш уліковы запіс быў дэактываваны. Звярніцеся да вашага сістэмнага адміністратара." + }, + { + "id": "api.user.login_by_intune.bot_login_forbidden.app_error", + "translation": "Уліковыя запісы ботаў не могуць уваходзіць з выкарыстаннем аўтэнтыфікацыі Microsoft." + }, + { + "id": "api.user.login_by_intune.not_available.app_error", + "translation": "Аўтэнтыфікацыя Microsoft Intune недаступная." + }, + { + "id": "api.user.login_by_intune.not_configured.app_error", + "translation": "Аўтэнтыфікацыя Microsoft Intune не наладжана." + }, + { + "id": "api.user.oauth_to_email.magic_link.app_error", + "translation": "Magic Link - гэта адзіны даступны метад уваходу для гэтага ўліковага запісу." + }, + { + "id": "api.user.update_password.magic_link.app_error", + "translation": "Немагчыма абнавіць пароль для ўліковых запісаў Magic Link." + }, + { + "id": "app.burn_post.not_burn_on_read.app_error", + "translation": "Гэта паведамленне не з'яўляецца паведамленнем з самазнішчэннем і не можа быць выдалена." + }, + { + "id": "app.burn_post.not_revealed.app_error", + "translation": "Гэта паведамленне з самазнішчэннем яшчэ не было раскрыта. Вы павінны раскрыць яго перад выдаленнем." + }, + { + "id": "app.burn_post.read_receipt.get.error", + "translation": "Адбылася памылка пры атрыманні пацвярджэння прачытання." + }, + { + "id": "app.burn_post.read_receipt.update.error", + "translation": "Адбылася памылка пры абнаўленні пацвярджэння прачытання." + }, + { + "id": "app.post.get_post.app_error", + "translation": "Адбылася памылка пры атрыманні зместу паведамлення з самазнішчэннем." + }, + { + "id": "app.reveal_post.cannot_reveal_own_post.app_error", + "translation": "Вы не можаце раскрыць сваё ўласнае паведамленне з самазнішчэннем." + }, + { + "id": "app.reveal_post.missing_expire_at.app_error", + "translation": "У паведамленні \"з самазнішчэннем\" адсутнічае неабходны час дзеяння." + }, + { + "id": "app.reveal_post.not_burn_on_read.app_error", + "translation": "Гэта паведамленне не з'яўляецца паведамленнем з самазнішчэннем і не можа быць раскрыта." + }, + { + "id": "app.reveal_post.post_expired.app_error", + "translation": "Гэта паведамленне з самазнішчэннем скончылася і больш не можа быць раскрыта." + }, + { + "id": "app.reveal_post.read_receipt.get.error", + "translation": "Адбылася памылка пры атрыманні пацвярджэння прачытання." + }, + { + "id": "app.reveal_post.read_receipt.get_unread_count.error", + "translation": "Немагчыма атрымаць колькасць непрачытаных пацвярджэнняў прачытання." + }, + { + "id": "app.reveal_post.read_receipt.save.error", + "translation": "Адбылася памылка пры захаванні пацвярджэння прачытання." + }, + { + "id": "app.reveal_post.read_receipt_expired.error", + "translation": "Ваш доступ да гэтага паведамлення з самазнішчэннем скончыўся." + }, + { + "id": "ent.intune.login.account_not_found.app_error", + "translation": "Ваш уліковы запіс яшчэ не цалкам наладжаны. Калі ласка, спачатку ўвайдзіце ў Mattermost праз вэб- або настольную праграму." + }, + { + "id": "ent.intune.login.extract_auth_data.app_error", + "translation": "Аўтэнтыфікацыя не ўдалася. Паспрабуйце яшчэ раз або звярніцеся да вашага сістэмнага адміністратара." + }, + { + "id": "ent.intune.login.not_configured.app_error", + "translation": "Аўтэнтыфікацыя Microsoft Intune не наладжана." + }, + { + "id": "ent.intune.validate_token.invalid_tenant_id.app_error", + "translation": "Аўтэнтыфікацыя не ўдалася. Няслушная канфігурацыя." + }, + { + "id": "ent.intune.validate_token.invalid_token.app_error", + "translation": "Аўтэнтыфікацыя не ўдалася. Паспрабуйце яшчэ раз." + }, + { + "id": "ent.intune.validate_token.jwks_init.app_error", + "translation": "Ініцыялізацыя службы аўтэнтыфікацыі не ўдалася. Звярніцеся да вашага сістэмнага адміністратара." + }, + { + "id": "ent.intune.validate_token.missing_claims.app_error", + "translation": "Аўтэнтыфікацыя не ўдалася. Адсутнічаюць неабходныя звесткі пра карыстальніка." + }, + { + "id": "ent.intune.validate_token.token_expired.app_error", + "translation": "Сесія аўтэнтыфікацыі скончылася. Паспрабуйце ўвайсці яшчэ раз." + }, + { + "id": "model.config.is_valid.intune_auth_service.app_error", + "translation": "Служба аўтэнтыфікацыі Intune MAM патрабуецца, калі Intune уключаны." + }, + { + "id": "model.config.is_valid.intune_auth_service_invalid.app_error", + "translation": "Служба аўтэнтыфікацыі Intune MAM павінна быць \"office365\" або \"saml\"." + }, + { + "id": "model.config.is_valid.intune_client_id.app_error", + "translation": "Ідэнтыфікатар кліента Intune MAM патрабуецца, калі Intune уключаны." + }, + { + "id": "model.config.is_valid.intune_client_id_format.app_error", + "translation": "Ідэнтыфікатар кліента Intune MAM павінен быць сапраўдным UUID." + }, + { + "id": "model.config.is_valid.intune_requires_office365.app_error", + "translation": "Intune MAM патрабуе, каб OpenID Connect (Office 365) быў уключаны, калі Служба аўтэнтыфікацыі ўстаноўлена на \"office365\"." + }, + { + "id": "model.config.is_valid.intune_requires_saml.app_error", + "translation": "Intune MAM патрабуе ўключэння SAML, калі Служба аўтэнтыфікацыі ўстаноўлена на \"saml\"." + }, + { + "id": "model.config.is_valid.intune_tenant_id.app_error", + "translation": "Ідэнтыфікатар тэнанта Intune MAM патрабуецца, калі Intune уключаны." + }, + { + "id": "model.config.is_valid.intune_tenant_id_format.app_error", + "translation": "Ідэнтыфікатар тэнанта Intune MAM павінен быць сапраўдным UUID." + }, + { + "id": "api.content_flagging.error.invalid_post_type", + "translation": "Маркіроўка паведамлення тыпу '{{.PostType}}' забаронена." + }, + { + "id": "model.config.is_valid.guest_accounts.cannot_enforce_multifactor_authentication_when_guest_magic_link_is_enabled.app_error", + "translation": "Вы не можаце прымусіць шматфактарную аўтэнтыфікацыю для ўліковых запісаў гасцей, калі ўключана аўтэнтыфікацыя па чароўнай спасылцы." + }, + { + "id": "model.config.is_valid.persistent_notifications_interval.app_error", + "translation": "Няправільная частата пастаянных апавяшчэнняў. Павінна быць не менш за дзве хвіліны." + }, + { + "id": "model.config.is_valid.persistent_notifications_recipients.app_error", + "translation": "Няправільная максімальная колькасць атрымальнікаў для пастаянных апавяшчэнняў. Павінна быць станоўчым лікам." + }, + { + "id": "api.file.zip_file_reader.app_error", + "translation": "Немагчыма атрымаць праграму для чытання zip-файлаў." + }, + { + "id": "app.import.validate_attachment_import_data.invalid_path.error", + "translation": "Не атрымалася праверыць даныя імпарту ўкладанняў. Няправільны шлях: \"{{.Path}}\"" + }, + { + "id": "app.import.validate_direct_post_import_data.attachment.error", + "translation": "Не атрымалася праверыць даныя ўкладанняў прамых паведамленняў." + }, + { + "id": "app.content_flagging.search_property_fields.app_error", + "translation": "Не атрымалася знайсці палі ўласцівасцей Content Flagging." + }, + { + "id": "app.content_flagging.search_property_values.app_error", + "translation": "Не атрымалася атрымаць значэнні ўласцівасцей flagowania treści posta з базы дадзеных." + }, + { + "id": "app.scheduled_post.failed_messages", + "translation": { + "few": "Не ўдалося адправіць {{.Count}} запланаваных допіса.", + "many": "Не ўдалося адправіць {{.Count}} запланаваных допісаў.", + "one": "Не ўдалося адправіць {{.Count}} запланаваны допіс." + } + }, + { + "id": "app.scheduled_post.failed_message_detail", + "translation": { + "few": "- {{.Count}} ў канале {{.ChannelName}}. Прычына: {{.ErrorReason}}", + "many": "- {{.Count}} у канале {{.ChannelName}}. Прычына: {{.ErrorReason}}", + "one": "- {{.Count}} у канале {{.ChannelName}}. Прычына: {{.ErrorReason}}" + } + }, + { + "id": "app.update_scheduled_post.convert_to_post.error", + "translation": "Немагчыма пераўтварыць запланаваны допіс у фармат допісу." } ] diff --git a/server/i18n/de.json b/server/i18n/de.json index 9a1d5dae453..ff7c0a75f0a 100644 --- a/server/i18n/de.json +++ b/server/i18n/de.json @@ -7175,7 +7175,7 @@ }, { "id": "api.templates.email_footer_v2", - "translation": "© 2022 Mattermost, Inc. 530 Lytton Avenue, Second floor, Palo Alto, CA, 94301" + "translation": "© 2015 - {{ .CurrentYear }} Mattermost, Inc. 2100 Geng Road, Suite 210, Palo Alto, CA, 94303" }, { "id": "api.templates.cloud_welcome_email.title", @@ -11682,5 +11682,9 @@ { "id": "model.config.is_valid.guest_accounts.cannot_enforce_multifactor_authentication_when_guest_magic_link_is_enabled.app_error", "translation": "Du kannst keine Multifaktor-Authentifizierung für Gastkonten erzwingen, wenn die Authentifizierung über einen magischen Link aktiviert ist." + }, + { + "id": "app.update_scheduled_post.convert_to_post.error", + "translation": "Geplante Nachricht kann nicht in das Nachrichtenformat konvertiert werden." } ] diff --git a/server/i18n/en.json b/server/i18n/en.json index fdf800f9bd4..1607abda20f 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -3018,6 +3018,14 @@ "id": "api.reaction.save_reaction.user_id.app_error", "translation": "You cannot save reaction for the other user." }, + { + "id": "api.recap.disabled.app_error", + "translation": "This feature is not enabled." + }, + { + "id": "api.recap.permission_denied", + "translation": "You do not have permission to access this recap." + }, { "id": "api.remote_cluster.accept_invitation_error", "translation": "Could not accept the remote cluster invitation" @@ -4850,6 +4858,14 @@ "id": "app.agents.get_services.bridge_call_failed", "translation": "Bridge call failed." }, + { + "id": "app.ai.summarize.agent_call_failed", + "translation": "AI agent call failed." + }, + { + "id": "app.ai.summarize.parse_failed", + "translation": "Failed to parse AI summarization response." + }, { "id": "app.analytics.getanalytics.internal_error", "translation": "Unable to get the analytics." @@ -7366,6 +7382,58 @@ "id": "app.reaction.save.save.too_many_reactions", "translation": "Reaction limit has been reached for this post." }, + { + "id": "app.recap.delete.app_error", + "translation": "Failed to delete recap." + }, + { + "id": "app.recap.delete_channels.app_error", + "translation": "Failed to delete recap channels." + }, + { + "id": "app.recap.get.app_error", + "translation": "Failed to get recap." + }, + { + "id": "app.recap.get_channel.app_error", + "translation": "Failed to get channel." + }, + { + "id": "app.recap.get_channels.app_error", + "translation": "Failed to get recap channels." + }, + { + "id": "app.recap.get_last_viewed.app_error", + "translation": "Failed to get last viewed timestamp." + }, + { + "id": "app.recap.get_team.app_error", + "translation": "Failed to get team." + }, + { + "id": "app.recap.list.app_error", + "translation": "Failed to get recaps." + }, + { + "id": "app.recap.mark_read.app_error", + "translation": "Failed to mark recap as read." + }, + { + "id": "app.recap.permission_denied", + "translation": "No permission for recap." + }, + { + "id": "app.recap.save.app_error", + "translation": "Failed to save recap." + }, + { + "id": "app.recap.save_channel.app_error", + "translation": "Failed to save recap channel." + }, + { + "id": "app.recap.update.app_error", + "translation": "Failed to update recap." + }, { "id": "app.recover.delete.app_error", "translation": "Unable to delete token." @@ -7928,6 +7996,10 @@ "id": "app.update_error", "translation": "update error" }, + { + "id": "app.update_scheduled_post.convert_to_post.error", + "translation": "Unable to convert scheduled post to post format." + }, { "id": "app.update_scheduled_post.existing_scheduled_post.not_exist", "translation": "Scheduled post does not exist." @@ -11578,11 +11650,11 @@ }, { "id": "web.error.unsupported_browser.min_browser_version.chrome", - "translation": "Version 140+" + "translation": "Version 142+" }, { "id": "web.error.unsupported_browser.min_browser_version.edge", - "translation": "Version 140+" + "translation": "Version 142+" }, { "id": "web.error.unsupported_browser.min_browser_version.firefox", diff --git a/server/i18n/hu.json b/server/i18n/hu.json index 840170820d7..8d7abf55580 100644 --- a/server/i18n/hu.json +++ b/server/i18n/hu.json @@ -8420,7 +8420,7 @@ }, { "id": "api.acknowledgement.save.archived_channel.app_error", - "translation": "Egy archivált csatornán nem lehet visszaigazolni." + "translation": "Az archivált csatornában nem lehet visszaigazolást menteni." }, { "id": "api.admin.syncables_error", diff --git a/server/i18n/nb-NO.json b/server/i18n/nb-NO.json index aa117c4c8fc..3b9aac75b35 100644 --- a/server/i18n/nb-NO.json +++ b/server/i18n/nb-NO.json @@ -599,5 +599,13 @@ { "id": "api.user.login_by_oauth.not_available.app_error", "translation": "{{.Service}} SSO via OAuth 2.0 er ikke tilgjengelig på denne serveren." + }, + { + "id": "api.access_control_policy.get_channels.limit.app_error", + "translation": "Grensen for å hente kanaler er ikke gyldig." + }, + { + "id": "api.admin.add_certificate.parseform.app_error", + "translation": "Feil ved analyse av multiform forespørsel" } ] diff --git a/server/i18n/nl.json b/server/i18n/nl.json index e5a576ed0f0..d3fb3fecaec 100644 --- a/server/i18n/nl.json +++ b/server/i18n/nl.json @@ -7312,7 +7312,7 @@ }, { "id": "api.templates.email_footer_v2", - "translation": "© 2022 Mattermost, Inc. 530 Lytton Avenue, Second Floor, Palo Alto, CA, 94301" + "translation": "© 2015 - {{ .CurrentYear }} Mattermost, Inc. 2100 Geng Road, Suite 210, Palo Alto, CA, 94303" }, { "id": "api.post.search_files.invalid_body.app_error", @@ -11694,5 +11694,9 @@ { "id": "model.config.is_valid.guest_accounts.cannot_enforce_multifactor_authentication_when_guest_magic_link_is_enabled.app_error", "translation": "Je kunt multifactorauthenticatie niet afdwingen voor gastaccounts als magische linkauthenticatie is ingeschakeld." + }, + { + "id": "app.update_scheduled_post.convert_to_post.error", + "translation": "Kan gepland bericht niet omzetten naar berichtformaat." } ] diff --git a/server/i18n/pl.json b/server/i18n/pl.json index b6c0c3d5ac9..1134ddf66d9 100644 --- a/server/i18n/pl.json +++ b/server/i18n/pl.json @@ -7093,7 +7093,7 @@ }, { "id": "api.templates.email_footer_v2", - "translation": "© 2022 Mattermost, Inc. 530 Lytton Avenue, Second floor, Palo Alto, CA, 94301" + "translation": "© 2015 - {{ .CurrentYear }} Mattermost, Inc. 2100 Geng Road, Suite 210, Palo Alto, CA, 94303" }, { "id": "api.templates.cloud_welcome_email.invite_info", @@ -11686,5 +11686,9 @@ { "id": "model.config.is_valid.guest_accounts.cannot_enforce_multifactor_authentication_when_guest_magic_link_is_enabled.app_error", "translation": "Nie możesz wymusić uwierzytelniania wieloskładnikowego dla kont gości, gdy włączone jest uwierzytelnianie magic link." + }, + { + "id": "app.update_scheduled_post.convert_to_post.error", + "translation": "Nie można przekonwertować zaplanowanego postu na format postu." } ] diff --git a/server/i18n/sl.json b/server/i18n/sl.json index 5c67b7c36f5..66d325c230b 100644 --- a/server/i18n/sl.json +++ b/server/i18n/sl.json @@ -4273,7 +4273,7 @@ }, { "id": "api.channel.delete_channel.cannot.app_error", - "translation": "Privzetega kanala ni mogoče izbrisati {{.Channel}}" + "translation": "Privzetega kanala ni mogoče izbrisati {{.Channel}}." }, { "id": "api.channel.delete_channel.archived", diff --git a/server/i18n/sv.json b/server/i18n/sv.json index d5b4085e0d1..3dabd2ef211 100644 --- a/server/i18n/sv.json +++ b/server/i18n/sv.json @@ -11254,5 +11254,13 @@ { "id": "api.post.update_post.burn_on_read.app_error", "translation": "Inlägg som bränns vid läsning kan inte redigeras." + }, + { + "id": "api.content_flagging.error.invalid_post_type", + "translation": "Att flagga ett inlägg av typen '{{.PostType}}' tillåts inte." + }, + { + "id": "ent.push_proxy.worker.interface_nil.app_error", + "translation": "Push-proxy-gränssnittet är tomt." } ] diff --git a/server/i18n/uk.json b/server/i18n/uk.json index af309423adc..055cfcdbcc5 100644 --- a/server/i18n/uk.json +++ b/server/i18n/uk.json @@ -7677,7 +7677,7 @@ }, { "id": "api.templates.email_footer_v2", - "translation": "© 2022 Mattermost, Inc. 530 Lytton Avenue, Second floor, Palo Alto, CA, 94301" + "translation": "© 2015 - {{ .CurrentYear }} Mattermost, Inc. 2100 Geng Road, Suite 210, Palo Alto, CA, 94303" }, { "id": "api.templates.invite_team_and_channel_subject", diff --git a/server/platform/services/sharedchannel/mock_AppIface_test.go b/server/platform/services/sharedchannel/mock_AppIface_test.go index 13e3d194b2e..91d665080d3 100644 --- a/server/platform/services/sharedchannel/mock_AppIface_test.go +++ b/server/platform/services/sharedchannel/mock_AppIface_test.go @@ -142,7 +142,7 @@ func (_m *MockAppIface) CreateGroupChannel(rctx request.CTX, userIDs []string, c } // CreatePost provides a mock function with given fields: rctx, post, channel, flags -func (_m *MockAppIface) CreatePost(rctx request.CTX, post *model.Post, channel *model.Channel, flags model.CreatePostFlags) (*model.Post, *model.AppError) { +func (_m *MockAppIface) CreatePost(rctx request.CTX, post *model.Post, channel *model.Channel, flags model.CreatePostFlags) (*model.Post, bool, *model.AppError) { ret := _m.Called(rctx, post, channel, flags) if len(ret) == 0 { @@ -150,8 +150,9 @@ func (_m *MockAppIface) CreatePost(rctx request.CTX, post *model.Post, channel * } var r0 *model.Post - var r1 *model.AppError - if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, *model.Channel, model.CreatePostFlags) (*model.Post, *model.AppError)); ok { + var r1 bool + var r2 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, *model.Channel, model.CreatePostFlags) (*model.Post, bool, *model.AppError)); ok { return rf(rctx, post, channel, flags) } if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, *model.Channel, model.CreatePostFlags) *model.Post); ok { @@ -162,15 +163,21 @@ func (_m *MockAppIface) CreatePost(rctx request.CTX, post *model.Post, channel * } } - if rf, ok := ret.Get(1).(func(request.CTX, *model.Post, *model.Channel, model.CreatePostFlags) *model.AppError); ok { + if rf, ok := ret.Get(1).(func(request.CTX, *model.Post, *model.Channel, model.CreatePostFlags) bool); ok { r1 = rf(rctx, post, channel, flags) } else { - if ret.Get(1) != nil { - r1 = ret.Get(1).(*model.AppError) + r1 = ret.Get(1).(bool) + } + + if rf, ok := ret.Get(2).(func(request.CTX, *model.Post, *model.Channel, model.CreatePostFlags) *model.AppError); ok { + r2 = rf(rctx, post, channel, flags) + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).(*model.AppError) } } - return r0, r1 + return r0, r1, r2 } // CreateUploadSession provides a mock function with given fields: rctx, us @@ -707,7 +714,7 @@ func (_m *MockAppIface) SaveReactionForPost(rctx request.CTX, reaction *model.Re } // SendEphemeralPost provides a mock function with given fields: rctx, userId, post -func (_m *MockAppIface) SendEphemeralPost(rctx request.CTX, userId string, post *model.Post) *model.Post { +func (_m *MockAppIface) SendEphemeralPost(rctx request.CTX, userId string, post *model.Post) (*model.Post, bool) { ret := _m.Called(rctx, userId, post) if len(ret) == 0 { @@ -715,6 +722,10 @@ func (_m *MockAppIface) SendEphemeralPost(rctx request.CTX, userId string, post } var r0 *model.Post + var r1 bool + if rf, ok := ret.Get(0).(func(request.CTX, string, *model.Post) (*model.Post, bool)); ok { + return rf(rctx, userId, post) + } if rf, ok := ret.Get(0).(func(request.CTX, string, *model.Post) *model.Post); ok { r0 = rf(rctx, userId, post) } else { @@ -723,11 +734,17 @@ func (_m *MockAppIface) SendEphemeralPost(rctx request.CTX, userId string, post } } - return r0 + if rf, ok := ret.Get(1).(func(request.CTX, string, *model.Post) bool); ok { + r1 = rf(rctx, userId, post) + } else { + r1 = ret.Get(1).(bool) + } + + return r0, r1 } // UpdatePost provides a mock function with given fields: rctx, post, updatePostOptions -func (_m *MockAppIface) UpdatePost(rctx request.CTX, post *model.Post, updatePostOptions *model.UpdatePostOptions) (*model.Post, *model.AppError) { +func (_m *MockAppIface) UpdatePost(rctx request.CTX, post *model.Post, updatePostOptions *model.UpdatePostOptions) (*model.Post, bool, *model.AppError) { ret := _m.Called(rctx, post, updatePostOptions) if len(ret) == 0 { @@ -735,8 +752,9 @@ func (_m *MockAppIface) UpdatePost(rctx request.CTX, post *model.Post, updatePos } var r0 *model.Post - var r1 *model.AppError - if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, *model.UpdatePostOptions) (*model.Post, *model.AppError)); ok { + var r1 bool + var r2 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, *model.UpdatePostOptions) (*model.Post, bool, *model.AppError)); ok { return rf(rctx, post, updatePostOptions) } if rf, ok := ret.Get(0).(func(request.CTX, *model.Post, *model.UpdatePostOptions) *model.Post); ok { @@ -747,15 +765,21 @@ func (_m *MockAppIface) UpdatePost(rctx request.CTX, post *model.Post, updatePos } } - if rf, ok := ret.Get(1).(func(request.CTX, *model.Post, *model.UpdatePostOptions) *model.AppError); ok { + if rf, ok := ret.Get(1).(func(request.CTX, *model.Post, *model.UpdatePostOptions) bool); ok { r1 = rf(rctx, post, updatePostOptions) } else { - if ret.Get(1) != nil { - r1 = ret.Get(1).(*model.AppError) + r1 = ret.Get(1).(bool) + } + + if rf, ok := ret.Get(2).(func(request.CTX, *model.Post, *model.UpdatePostOptions) *model.AppError); ok { + r2 = rf(rctx, post, updatePostOptions) + } else { + if ret.Get(2) != nil { + r2 = ret.Get(2).(*model.AppError) } } - return r0, r1 + return r0, r1, r2 } // UserCanSeeOtherUser provides a mock function with given fields: rctx, userID, otherUserId diff --git a/server/platform/services/sharedchannel/permalink_test.go b/server/platform/services/sharedchannel/permalink_test.go index 940b8df0d66..a31d2ed16c5 100644 --- a/server/platform/services/sharedchannel/permalink_test.go +++ b/server/platform/services/sharedchannel/permalink_test.go @@ -38,7 +38,7 @@ func TestProcessPermalinkToRemote(t *testing.T) { mockServer.On("Log").Return(logger) mockApp := scs.app.(*MockAppIface) - mockApp.On("SendEphemeralPost", mock.Anything, "user", mock.AnythingOfType("*model.Post")).Return(&model.Post{}).Times(1) + mockApp.On("SendEphemeralPost", mock.Anything, "user", mock.AnythingOfType("*model.Post")).Return(&model.Post{}, true).Times(1) defer mockApp.AssertExpectations(t) t.Run("same channel", func(t *testing.T) { diff --git a/server/platform/services/sharedchannel/service.go b/server/platform/services/sharedchannel/service.go index daf07f167ec..8d5afca8092 100644 --- a/server/platform/services/sharedchannel/service.go +++ b/server/platform/services/sharedchannel/service.go @@ -54,7 +54,7 @@ type PlatformIface interface { } type AppIface interface { - SendEphemeralPost(rctx request.CTX, userId string, post *model.Post) *model.Post + SendEphemeralPost(rctx request.CTX, userId string, post *model.Post) (*model.Post, bool) CreateChannelWithUser(rctx request.CTX, channel *model.Channel, userId string) (*model.Channel, *model.AppError) GetOrCreateDirectChannel(rctx request.CTX, userId, otherUserId string, channelOptions ...model.ChannelOption) (*model.Channel, *model.AppError) CreateGroupChannel(rctx request.CTX, userIDs []string, creatorId string, channelOptions ...model.ChannelOption) (*model.Channel, *model.AppError) @@ -63,8 +63,8 @@ type AppIface interface { AddUserToTeamByTeamId(rctx request.CTX, teamId string, user *model.User) *model.AppError RemoveUserFromChannel(rctx request.CTX, userID string, removerUserId string, channel *model.Channel) *model.AppError PermanentDeleteChannel(rctx request.CTX, channel *model.Channel) *model.AppError - CreatePost(rctx request.CTX, post *model.Post, channel *model.Channel, flags model.CreatePostFlags) (savedPost *model.Post, err *model.AppError) - UpdatePost(rctx request.CTX, post *model.Post, updatePostOptions *model.UpdatePostOptions) (*model.Post, *model.AppError) + CreatePost(rctx request.CTX, post *model.Post, channel *model.Channel, flags model.CreatePostFlags) (savedPost *model.Post, isMemberForPreviews bool, err *model.AppError) + UpdatePost(rctx request.CTX, post *model.Post, updatePostOptions *model.UpdatePostOptions) (*model.Post, bool, *model.AppError) DeletePost(rctx request.CTX, postID, deleteByID string) (*model.Post, *model.AppError) SaveReactionForPost(rctx request.CTX, reaction *model.Reaction) (*model.Reaction, *model.AppError) DeleteReactionForPost(rctx request.CTX, reaction *model.Reaction) *model.AppError @@ -315,7 +315,7 @@ func (scs *Service) postUnshareNotification(channelID string, creatorID string, } logger := scs.server.Log() - _, appErr := scs.app.CreatePost(request.EmptyContext(logger), post, channel, model.CreatePostFlags{}) + _, _, appErr := scs.app.CreatePost(request.EmptyContext(logger), post, channel, model.CreatePostFlags{}) if appErr != nil { scs.server.Log().Log( diff --git a/server/platform/services/sharedchannel/sync_recv.go b/server/platform/services/sharedchannel/sync_recv.go index ab30b0ac8c1..60dddad1fa9 100644 --- a/server/platform/services/sharedchannel/sync_recv.go +++ b/server/platform/services/sharedchannel/sync_recv.go @@ -493,7 +493,7 @@ func (scs *Service) upsertSyncPost(post *model.Post, targetChannel *model.Channe scs.transformMentionsOnReceive(rctx, post, targetChannel, rc, mentionTransforms) - rpost, appErr = scs.app.CreatePost(rctx, post, targetChannel, model.CreatePostFlags{TriggerWebhooks: true, SetOnline: true}) + rpost, _, appErr = scs.app.CreatePost(rctx, post, targetChannel, model.CreatePostFlags{TriggerWebhooks: true, SetOnline: true}) if appErr == nil { scs.server.Log().Log(mlog.LvlSharedChannelServiceDebug, "Created sync post", mlog.String("post_id", post.Id), @@ -527,7 +527,7 @@ func (scs *Service) upsertSyncPost(post *model.Post, targetChannel *model.Channe } // First update the basic post - rpost, appErr = scs.app.UpdatePost(rctx, post, nil) + rpost, _, appErr = scs.app.UpdatePost(rctx, post, nil) if appErr != nil { rerr := errors.New(appErr.Error()) return nil, rerr diff --git a/server/public/go.mod b/server/public/go.mod index e991f99be0f..d30d9ef38ca 100644 --- a/server/public/go.mod +++ b/server/public/go.mod @@ -1,6 +1,6 @@ module github.com/mattermost/mattermost/server/public -go 1.24.6 +go 1.24.11 require ( github.com/blang/semver/v4 v4.0.0 diff --git a/server/public/model/audit_events.go b/server/public/model/audit_events.go index 9c536a033b8..0d28b80241c 100644 --- a/server/public/model/audit_events.go +++ b/server/public/model/audit_events.go @@ -44,6 +44,7 @@ const ( AuditEventDeleteChannelBookmark = "deleteChannelBookmark" // delete bookmark AuditEventUpdateChannelBookmark = "updateChannelBookmark" // update bookmark AuditEventUpdateChannelBookmarkSortOrder = "updateChannelBookmarkSortOrder" // update display order of bookmarks + AuditEventListChannelBookmarksForChannel = "listChannelBookmarksForChannel" // list bookmarks for channel ) // Channel Categories @@ -63,6 +64,7 @@ const ( AuditEventCreateDirectChannel = "createDirectChannel" // create direct message channel between two users AuditEventCreateGroupChannel = "createGroupChannel" // create group message channel with multiple users AuditEventDeleteChannel = "deleteChannel" // delete channel + AuditEventGetPinnedPosts = "getPinnedPosts" // get pinned posts AuditEventLocalAddChannelMember = "localAddChannelMember" // add channel member locally AuditEventLocalCreateChannel = "localCreateChannel" // create channel locally AuditEventLocalDeleteChannel = "localDeleteChannel" // delete channel locally @@ -156,6 +158,11 @@ const ( AuditEventUploadFileMultipart = "uploadFileMultipart" // upload file using multipart form data AuditEventUploadFileMultipartLegacy = "uploadFileMultipartLegacy" // upload file using legacy multipart method AuditEventUploadFileSimple = "uploadFileSimple" // upload file using simple direct upload method + AuditEventGetFileThumbnail = "getFileThumbnail" // get file thumbnail + AuditEventGetFileInfosForPost = "getFileInfosForPost" // get file infos for post + AuditEventGetFileInfo = "getFileInfo" // get file info + AuditEventGetFilePreview = "getFilePreview" // get file preview + AuditEventSearchFiles = "searchFiles" // search for files ) // Groups @@ -244,17 +251,38 @@ const ( // Posts const ( - AuditEventCreatePost = "createPost" // create post - AuditEventDeletePost = "deletePost" // delete post - AuditEventLocalDeletePost = "localDeletePost" // delete post locally - AuditEventMoveThread = "moveThread" // move thread and replies to different channel - AuditEventPatchPost = "patchPost" // update post meta properties - AuditEventRestorePostVersion = "restorePostVersion" // restore post to previous version - AuditEventSaveIsPinnedPost = "saveIsPinnedPost" // pin or unpin post - AuditEventSearchPosts = "searchPosts" // search for posts - AuditEventUpdatePost = "updatePost" // update post content - AuditEventRevealPost = "revealPost" // reveal a post that was hidden due to burn on read - AuditEventBurnPost = "burnPost" // burn a post that was hidden due to burn on read + AuditEventCreateEphemeralPost = "createEphemeralPost" // create ephemeral post + AuditEventCreatePost = "createPost" // create post + AuditEventDeletePost = "deletePost" // delete post + AuditEventGetEditHistoryForPost = "getEditHistoryForPost" // get edit history for post + AuditEventGetFlaggedPosts = "getFlaggedPosts" // get flagged posts + AuditEventGetPostsForChannel = "getPostsForChannel" // get posts for channel + AuditEventGetPostsForChannelAroundLastUnread = "getPostsForChannelAroundLastUnread" // get posts for channel around last unread + AuditEventGetPost = "getPost" // get post + AuditEventGetPostThread = "getPostThread" // get post thread + AuditEventGetPostsByIds = "getPostsByIds" // get posts by ids + AuditEventGetThreadForUser = "getThreadForUser" // get thread for user + AuditEventLocalDeletePost = "localDeletePost" // delete post locally + AuditEventMoveThread = "moveThread" // move thread and replies to different channel + AuditEventNotificationAck = "notificationAck" // notification ack + AuditEventPatchPost = "patchPost" // update post meta properties + AuditEventRestorePostVersion = "restorePostVersion" // restore post to previous version + AuditEventSaveIsPinnedPost = "saveIsPinnedPost" // pin or unpin post + AuditEventSearchPosts = "searchPosts" // search for posts + AuditEventUpdatePost = "updatePost" // update post content + AuditEventRevealPost = "revealPost" // reveal a post that was hidden due to burn on read + AuditEventBurnPost = "burnPost" // burn a post that was hidden due to burn on read + AuditEventWebsocketPost = "websocketPost" // post received via websocket +) + +// Recaps +const ( + AuditEventCreateRecap = "createRecap" // create recap summarizing channel content + AuditEventGetRecap = "getRecap" // view a single recap + AuditEventGetRecaps = "getRecaps" // list user's recaps + AuditEventMarkRecapAsRead = "markRecapAsRead" // mark recap as read + AuditEventRegenerateRecap = "regenerateRecap" // regenerate recap with updated channel content + AuditEventDeleteRecap = "deleteRecap" // delete recap ) // Preferences @@ -392,6 +420,7 @@ const ( AuditEventLocalDeleteUser = "localDeleteUser" // delete user locally AuditEventLocalPermanentDeleteAllUsers = "localPermanentDeleteAllUsers" // permanently delete all users locally AuditEventLogin = "login" // user login to system + AuditEventLoginWithDesktopToken = "loginWithDesktopToken" // user login to system with desktop token AuditEventLogout = "logout" // user logout from system AuditEventMigrateAuthToLdap = "migrateAuthToLdap" // migrate user authentication method to LDAP AuditEventMigrateAuthToSaml = "migrateAuthToSaml" // migrate user authentication method to SAML diff --git a/server/public/model/channel.go b/server/public/model/channel.go index 6de5e09e789..e174288b83d 100644 --- a/server/public/model/channel.go +++ b/server/public/model/channel.go @@ -265,10 +265,6 @@ func (o *Channel) DeepCopy() *Channel { return &cCopy } -func (o *Channel) Etag() string { - return Etag(o.Id, o.UpdateAt) -} - func (o *Channel) IsValid() *AppError { if !IsValidId(o.Id) { return NewAppError("Channel.IsValid", "model.channel.is_valid.id.app_error", nil, "", http.StatusBadRequest) diff --git a/server/public/model/channel_test.go b/server/public/model/channel_test.go index 36758bba6d1..794d2aa1677 100644 --- a/server/public/model/channel_test.go +++ b/server/public/model/channel_test.go @@ -152,7 +152,6 @@ func TestChannelBannerBackgroundColorValidation(t *testing.T) { func TestChannelPreSave(t *testing.T) { o := Channel{Name: "test"} o.PreSave() - o.Etag() } func TestChannelPreUpdate(t *testing.T) { diff --git a/server/public/model/client4.go b/server/public/model/client4.go index fc56f3b1480..e5f823466bc 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -2643,8 +2643,8 @@ func (c *Client4) CreateGroupChannel(ctx context.Context, userIds []string) (*Ch } // GetChannel returns a channel based on the provided channel id string. -func (c *Client4) GetChannel(ctx context.Context, channelId, etag string) (*Channel, *Response, error) { - r, err := c.DoAPIGet(ctx, c.channelRoute(channelId), etag) +func (c *Client4) GetChannel(ctx context.Context, channelId string) (*Channel, *Response, error) { + r, err := c.DoAPIGet(ctx, c.channelRoute(channelId), "") if err != nil { return nil, BuildResponse(r), err } diff --git a/server/public/model/config.go b/server/public/model/config.go index 77aa65e6596..bfc42f8156a 100644 --- a/server/public/model/config.go +++ b/server/public/model/config.go @@ -5061,49 +5061,39 @@ func (o *Config) Sanitize(pluginManifests []*Manifest, opts *SanitizeOptions) { o.PluginSettings.Sanitize(pluginManifests) } -// SanitizeDataSource redacts sensitive information (username and password) from a database +// SanitizeDataSource redacts sensitive information (username and password) from a PostgreSQL // connection string while preserving other connection parameters. // -// Parameters: -// - driverName: The database driver name (postgres or mysql) -// - dataSource: The database connection string to sanitize +// Example: // -// Returns: -// - The sanitized connection string with username/password replaced by SanitizedPassword -// - An error if the driverName is not supported or if parsing fails -// -// Examples: -// - PostgreSQL: "postgres://user:pass@host:5432/db" -> "postgres://****:****@host:5432/db" -// - MySQL: "user:pass@tcp(host:3306)/db" -> "****:****@tcp(host:3306)/db" +// "postgres://user:pass@host:5432/db" -> "postgres://****:****@host:5432/db" func SanitizeDataSource(driverName, dataSource string) (string, error) { - // Handle empty data source if dataSource == "" { return "", nil } - switch driverName { - case DatabaseDriverPostgres: - u, err := url.Parse(dataSource) - if err != nil { - return "", err - } - u.User = url.UserPassword(SanitizedPassword, SanitizedPassword) - - // Remove username and password from query string - params := u.Query() - params.Del("user") - params.Del("password") - u.RawQuery = params.Encode() - - // Unescape the URL to make it human-readable - out, err := url.QueryUnescape(u.String()) - if err != nil { - return "", err - } - return out, nil - default: - return "", errors.New("invalid drivername. Not postgres or mysql.") + if driverName != DatabaseDriverPostgres { + return "", errors.New("invalid drivername: only postgres is supported") } + + u, err := url.Parse(dataSource) + if err != nil { + return "", err + } + u.User = url.UserPassword(SanitizedPassword, SanitizedPassword) + + // Remove username and password from query string + params := u.Query() + params.Del("user") + params.Del("password") + u.RawQuery = params.Encode() + + // Unescape the URL to make it human-readable + out, err := url.QueryUnescape(u.String()) + if err != nil { + return "", err + } + return out, nil } type FilterTag struct { diff --git a/server/public/model/config_test.go b/server/public/model/config_test.go index 677b2d6c809..84b10030bdd 100644 --- a/server/public/model/config_test.go +++ b/server/public/model/config_test.go @@ -2525,7 +2525,7 @@ func TestFilterConfig(t *testing.T) { require.NoError(t, err) require.Empty(t, m) - cfg.SqlSettings.DriverName = NewPointer("mysql") + cfg.SqlSettings.DriverName = NewPointer("postgresql") m, err = FilterConfig(cfg, ConfigFilterOptions{ GetConfigOptions: GetConfigOptions{ RemoveDefaults: true, @@ -2534,7 +2534,7 @@ func TestFilterConfig(t *testing.T) { }) require.NoError(t, err) require.NotEmpty(t, m) - require.Equal(t, "mysql", m["SqlSettings"].(map[string]any)["DriverName"]) + require.Equal(t, "postgresql", m["SqlSettings"].(map[string]any)["DriverName"]) }) t.Run("should not clear non primitive types", func(t *testing.T) { diff --git a/server/public/model/example_channel_test.go b/server/public/model/example_channel_test.go index a6098f74895..6ba97c680e9 100644 --- a/server/public/model/example_channel_test.go +++ b/server/public/model/example_channel_test.go @@ -102,8 +102,7 @@ func ExampleClient4_GetChannel() { client.SetToken(os.Getenv("MM_AUTHTOKEN")) channelId := "channel_id" - etag := "" - channel, _, err := client.GetChannel(context.Background(), channelId, etag) + channel, _, err := client.GetChannel(context.Background(), channelId) if err != nil { log.Fatal(err) } diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index 8249f01d6a2..2e46ac42a09 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -88,6 +88,9 @@ type FeatureFlags struct { // FEATURE_FLAG_REMOVAL: EnableAIPluginBridge EnableAIPluginBridge bool + + // FEATURE_FLAG_REMOVAL: EnableAIRecaps - Remove this when GA is released + EnableAIRecaps bool } func (f *FeatureFlags) SetDefaults() { @@ -129,6 +132,8 @@ func (f *FeatureFlags) SetDefaults() { // FEATURE_FLAG_REMOVAL: EnableAIPluginBridge - Remove this default when MVP is to be released f.EnableAIPluginBridge = false + + f.EnableAIRecaps = false } // ToMap returns the feature flags as a map[string]string diff --git a/server/public/model/file_info.go b/server/public/model/file_info.go index 7be518c9942..d334246fab6 100644 --- a/server/public/model/file_info.go +++ b/server/public/model/file_info.go @@ -53,7 +53,7 @@ type FileInfo struct { Width int `json:"width,omitempty"` Height int `json:"height,omitempty"` HasPreviewImage bool `json:"has_preview_image,omitempty"` - MiniPreview *[]byte `json:"mini_preview"` // declared as *[]byte to avoid postgres/mysql differences in deserialization + MiniPreview *[]byte `json:"mini_preview"` // pointer to distinguish NULL (no preview) from empty data Content string `json:"-"` RemoteId *string `json:"remote_id"` Archived bool `json:"archived"` diff --git a/server/public/model/job.go b/server/public/model/job.go index 20068615e30..3382c920d1e 100644 --- a/server/public/model/job.go +++ b/server/public/model/job.go @@ -45,6 +45,7 @@ const ( JobTypeMobileSessionMetadata = "mobile_session_metadata" JobTypeAccessControlSync = "access_control_sync" JobTypePushProxyAuth = "push_proxy_auth" + JobTypeRecap = "recap" JobTypeDeleteExpiredPosts = "delete_expired_posts" JobStatusPending = "pending" diff --git a/server/public/model/link_metadata.go b/server/public/model/link_metadata.go index 4c53784e20c..7055e9845e0 100644 --- a/server/public/model/link_metadata.go +++ b/server/public/model/link_metadata.go @@ -136,10 +136,8 @@ func (o *LinkMetadata) DeserializeDataToConcreteType() error { var b []byte switch t := o.Data.(type) { case []byte: - // MySQL uses a byte slice for JSON b = t case string: - // Postgres uses a string for JSON b = []byte(t) } diff --git a/server/public/model/post.go b/server/public/model/post.go index 654492c80ab..1b16b170292 100644 --- a/server/public/model/post.go +++ b/server/public/model/post.go @@ -64,7 +64,7 @@ const ( PostFilenamesMaxRunes = 4000 PostHashtagsMaxRunes = 1000 PostMessageMaxRunesV1 = 4000 - PostMessageMaxBytesV2 = 65535 // Maximum size of a TEXT column in MySQL + PostMessageMaxBytesV2 = 65535 PostMessageMaxRunesV2 = PostMessageMaxBytesV2 / 4 // Assume a worst-case representation // Reporting API constants diff --git a/server/public/model/recap.go b/server/public/model/recap.go new file mode 100644 index 00000000000..d3f02fc4c57 --- /dev/null +++ b/server/public/model/recap.go @@ -0,0 +1,75 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package model + +type Recap struct { + Id string `json:"id"` + UserId string `json:"user_id"` + Title string `json:"title"` + CreateAt int64 `json:"create_at"` + UpdateAt int64 `json:"update_at"` + DeleteAt int64 `json:"delete_at"` + ReadAt int64 `json:"read_at"` + TotalMessageCount int `json:"total_message_count"` + Status string `json:"status"` + BotID string `json:"bot_id"` + Channels []*RecapChannel `json:"channels,omitempty"` +} + +type RecapChannel struct { + Id string `json:"id"` + RecapId string `json:"recap_id"` + ChannelId string `json:"channel_id"` + ChannelName string `json:"channel_name"` + Highlights []string `json:"highlights"` + ActionItems []string `json:"action_items"` + SourcePostIds []string `json:"source_post_ids"` + CreateAt int64 `json:"create_at"` +} + +type CreateRecapRequest struct { + Title string `json:"title"` + ChannelIds []string `json:"channel_ids"` + AgentID string `json:"agent_id"` +} + +type AIRecapSummaryResponse struct { + Highlights []string `json:"highlights"` + ActionItems []string `json:"action_items"` +} + +// RecapChannelResult represents the result of processing a single channel for a recap +type RecapChannelResult struct { + ChannelID string + MessageCount int + Success bool +} + +const ( + RecapStatusPending = "pending" + RecapStatusProcessing = "processing" + RecapStatusCompleted = "completed" + RecapStatusFailed = "failed" +) + +// Auditable returns safe-to-log fields for audit logging +func (r *Recap) Auditable() map[string]any { + channelIDs := make([]string, 0, len(r.Channels)) + for _, channel := range r.Channels { + channelIDs = append(channelIDs, channel.ChannelId) + } + + return map[string]any{ + "id": r.Id, + "user_id": r.UserId, + "title": r.Title, + "status": r.Status, + "channel_ids": channelIDs, + "total_message_count": r.TotalMessageCount, + "bot_id": r.BotID, + "create_at": r.CreateAt, + "update_at": r.UpdateAt, + "read_at": r.ReadAt, + } +} diff --git a/server/public/model/support_packet.go b/server/public/model/support_packet.go index 8f8dddae1a1..26e631a932a 100644 --- a/server/public/model/support_packet.go +++ b/server/public/model/support_packet.go @@ -27,6 +27,8 @@ type SupportPacketDiagnostics struct { Server struct { OS string `yaml:"os"` Architecture string `yaml:"architecture"` + CPUCores int `yaml:"cpu_cores"` + TotalMemoryMB uint64 `yaml:"total_memory_mb"` Hostname string `yaml:"hostname"` Version string `yaml:"version"` BuildHash string `yaml:"build_hash"` diff --git a/server/public/model/user_autocomplete.go b/server/public/model/user_autocomplete.go index b07131b387e..9c1f8a106f7 100644 --- a/server/public/model/user_autocomplete.go +++ b/server/public/model/user_autocomplete.go @@ -15,4 +15,5 @@ type UserAutocompleteInTeam struct { type UserAutocomplete struct { Users []*User `json:"users"` OutOfChannel []*User `json:"out_of_channel,omitempty"` + Agents []*User `json:"agents,omitempty"` } diff --git a/server/public/model/version.go b/server/public/model/version.go index 79e2a251a6d..a2d5e8223f5 100644 --- a/server/public/model/version.go +++ b/server/public/model/version.go @@ -13,6 +13,7 @@ import ( // It should be maintained in chronological order with most current // release at the front of the list. var versions = []string{ + "11.4.0", "11.3.0", "11.2.0", "11.1.0", diff --git a/server/public/model/websocket_message.go b/server/public/model/websocket_message.go index 1943affb18f..b8f2010ef7d 100644 --- a/server/public/model/websocket_message.go +++ b/server/public/model/websocket_message.go @@ -99,6 +99,7 @@ const ( WebsocketEventCPAFieldDeleted WebsocketEventType = "custom_profile_attributes_field_deleted" WebsocketEventCPAValuesUpdated WebsocketEventType = "custom_profile_attributes_values_updated" WebsocketContentFlaggingReportValueUpdated WebsocketEventType = "content_flagging_report_value_updated" + WebsocketEventRecapUpdated WebsocketEventType = "recap_updated" WebsocketEventPostRevealed WebsocketEventType = "post_revealed" WebsocketEventPostBurned WebsocketEventType = "post_burned" WebsocketEventBurnOnReadAllRevealed WebsocketEventType = "burn_on_read_all_revealed" diff --git a/server/public/plugin/client_rpc.go b/server/public/plugin/client_rpc.go index edbd3c04f1f..3f0ca30dbb4 100644 --- a/server/public/plugin/client_rpc.go +++ b/server/public/plugin/client_rpc.go @@ -28,8 +28,46 @@ import ( "github.com/mattermost/mattermost/server/public/shared/mlog" ) +// Plugin RPC Architecture +// +// Mattermost plugins run as separate OS processes for isolation and safety, using +// HashiCorp's go-plugin library. Communication between the server and plugins is +// bidirectional via RPC: +// +// ┌─────────────────────────┐ ┌─────────────────────────┐ +// │ Mattermost Server │ │ Plugin Process │ +// │ │ │ │ +// │ ┌───────────────────┐ │ hooks (calls) │ ┌───────────────────┐ │ +// │ │ hooksRPCClient │──┼───────────────────►│ │ hooksRPCServer │ │ +// │ └───────────────────┘ │ │ └───────────────────┘ │ +// │ │ │ │ +// │ ┌───────────────────┐ │ API (callbacks) │ ┌───────────────────┐ │ +// │ │ apiRPCServer │◄─┼────────────────────┼──│ apiRPCClient │ │ +// │ └───────────────────┘ │ │ └───────────────────┘ │ +// └─────────────────────────┘ └─────────────────────────┘ +// +// - Server → Plugin (Hooks): hooksRPCClient serializes hook calls and sends them +// to hooksRPCServer in the plugin process, which delegates to the plugin implementation. +// +// - Plugin → Server (API): apiRPCClient in the plugin serializes API calls and sends +// them to apiRPCServer in the server, which delegates to the Mattermost API. +// +// The MuxBroker enables multiplexed streaming connections over a single RPC connection, +// which is essential for efficiently streaming HTTP bodies, file uploads, and other +// large data transfers without buffering everything in memory. + var hookNameToId = make(map[string]int) +// hooksRPCClient is the client-side RPC proxy that runs in the Mattermost server process and connects to to the [hooksRPCServer] on the plugin side. +// It implements the Hooks interface and forwards hook invocations to plugins running in +// separate processes via RPC. +// +// When Mattermost needs to call a plugin hook (e.g., MessageWillBePosted), it calls the +// corresponding method on hooksRPCClient, which serializes the arguments and makes an +// RPC call to the plugin process where hooksRPCServer receives and handles it. +// +// The struct also holds references to the API and Driver implementations that will be +// exposed to the plugin via apiRPCServer when the plugin is activated. type hooksRPCClient struct { client *rpc.Client log *mlog.Logger @@ -40,6 +78,13 @@ type hooksRPCClient struct { doneWg sync.WaitGroup } +// hooksRPCServer is the server-side RPC handler that runs in the plugin process and receives requests from [hooksRPCClient]. +// It receives hook invocations from hooksRPCClient (in the Mattermost server) and +// delegates them to the actual plugin implementation. +// +// During plugin activation (OnActivate), it establishes a reverse RPC connection +// back to the server, creating an apiRPCClient that the plugin uses to call +// Mattermost APIs. type hooksRPCServer struct { impl any muxBroker *plugin.MuxBroker @@ -68,11 +113,22 @@ func (p *hooksPlugin) Client(b *plugin.MuxBroker, client *rpc.Client) (any, erro }, nil } +// apiRPCClient is the client-side RPC proxy that runs in the plugin process and connects to the [apiRPCServer] on the Mattermost server side. +// It implements the API interface and allows plugins to call Mattermost server +// APIs (e.g., GetUser, CreatePost) by forwarding requests via RPC to apiRPCServer. +// +// This is created during plugin activation and injected into the plugin via SetAPI(). type apiRPCClient struct { client *rpc.Client muxBroker *plugin.MuxBroker } +// apiRPCServer is the server-side RPC handler that runs in the Mattermost server process and receives requests from [apiRPCClient]. +// It receives API calls from plugins (via apiRPCClient) and delegates them to the actual +// Mattermost API implementation. +// +// This enables plugins to interact with Mattermost functionality like users, posts, +// channels, and configuration through a well-defined API boundary. type apiRPCServer struct { impl API muxBroker *plugin.MuxBroker diff --git a/server/public/plugin/driver.go b/server/public/plugin/driver.go index ba5c5d9aca4..ea420739fa3 100644 --- a/server/public/plugin/driver.go +++ b/server/public/plugin/driver.go @@ -54,9 +54,7 @@ type Driver interface { // TODO: add this // RowsColumnScanType(rowsID string, index int) reflect.Type - // Note: the following cannot be implemented because either MySQL or PG - // does not support it. So this implementation has to be a common subset - // of both DB implementations. + // Note: the following are not currently implemented. // RowsColumnTypeLength(rowsID string, index int) (int64, bool) // RowsColumnTypeNullable(rowsID string, index int) (bool, bool) // ResetSession(ctx context.Context) error diff --git a/server/scripts/esrupgrades/esr.5.37-6.3.mysql.cleanup.sql b/server/scripts/esrupgrades/esr.5.37-6.3.mysql.cleanup.sql deleted file mode 100644 index 3a13b11f83a..00000000000 --- a/server/scripts/esrupgrades/esr.5.37-6.3.mysql.cleanup.sql +++ /dev/null @@ -1,160 +0,0 @@ -/* Product notices are controlled externally, via the mattermost/notices repository. - When there is a new notice specified there, the server may have time, right after - the migration and before it is shut down, to download it and modify the - ProductNoticeViewState table, adding a row for all users that have not seen it or - removing old notices that no longer need to be shown. This can happen in the - UpdateProductNotices function that is executed periodically to update the notices - cache. The script will never do this, so we need to remove all rows in that table - to avoid any unwanted diff. */ -DELETE FROM ProductNoticeViewState; - -/* The script does not update the Systems row that tracks the version, so it is manually updated - here so that it does not show in the diff. */ -UPDATE Systems SET Value = '6.3.0' WHERE Name = 'Version'; - -/* The script does not update the schema_migrations table, which is automatically used by the - migrate library to track the version, so we drop it altogether to avoid spurious errors in - the diff */ -DROP TABLE IF EXISTS schema_migrations; - -/* Migration 000054_create_crt_channelmembership_count.up sets - ChannelMembers.LastUpdateAt to the results of SELECT ROUND(UNIX_TIMESTAMP(NOW(3))*1000) - which will be different each time the migration is run. Thus, the column will always be - different when comparing the server and script migrations. To bypass this, we update all - rows in ChannelMembers so that they contain the same value for such column. */ -UPDATE ChannelMembers SET LastUpdateAt = 1; - -/* Migration 000055_create_crt_thread_count_and_unreads.up sets - ThreadMemberships.LastUpdated to the results of SELECT ROUND(UNIX_TIMESTAMP(NOW(3))*1000) - which will be different each time the migration is run. Thus, the column will always be - different when comparing the server and script migrations. To bypass this, we update all - rows in ThreadMemberships so that they contain the same value for such column. */ -UPDATE ThreadMemberships SET LastUpdated = 1; - -/* The security update check in the server may update the LastSecurityTime system value. To - avoid any spurious difference in the migrations, we update it to a fixed value. */ -UPDATE Systems SET Value = 1 WHERE Name = 'LastSecurityTime'; - -/* The server migration contains an in-app migration that adds new roles for Playbooks: - doPlaybooksRolesCreationMigration, defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/migrations.go#L345-L469 - The roles are the ones defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/model/role.go#L874-L929 - When this migration finishes, it also adds a new row to the Systems table with the key of the migration. - This in-app migration does not happen in the script, so we remove those rows here. */ -DELETE FROM Roles WHERE Name = 'playbook_member'; -DELETE FROM Roles WHERE Name = 'playbook_admin'; -DELETE FROM Roles WHERE Name = 'run_member'; -DELETE FROM Roles WHERE Name = 'run_admin'; -DELETE FROM Systems WHERE Name = 'PlaybookRolesCreationMigrationComplete'; - -/* The server migration contains an in-app migration that add playbooks permissions to certain roles: - getAddPlaybooksPermissions, defined in https://github.com/mattermost/mattermost-server/blob/f9b996934cabf9a8fad5901835e7e9b418917402/app/permissions_migrations.go#L918-L951 - The specific roles ('%playbook%') are removed in the procedure below, but the migrations also add a new row to the Systems table marking the migration as complete. - This in-app migration does not happen in the script, so we remove that rows here. */ -DELETE FROM Systems WHERE Name = 'playbooks_permissions'; - -/* The rest of this script defines and executes a procedure to update the Roles table. It performs several changes: - 1. Set the UpdateAt column of all rows to a fixed value, so that the server migration changes to this column - do not appear in the diff. - 2. Remove the set of specific permissions added in the server migration that is not covered by the script, as - this logic happens all in-app after the normal DB migrations. - 3. Set a consistent order in the Permissions column, which is modelled a space-separated string containing each of - the different permissions each role has. This change is the reason why we need a complex procedure, which creates - a temporary table that pairs each single permission to its corresponding ID. So if the Roles table contains two - rows like: - Id: 'abcd' - Permissions: 'view_team read_public_channel invite_user' - Id: 'efgh' - Permissions: 'view_team create_emojis' - then the new temporary table will contain five rows like: - Id: 'abcd' - Permissions: 'view_team' - Id: 'abcd' - Permissions: 'read_public_channel' - Id: 'abcd' - Permissions: 'invite_user' - Id: 'efgh' - Permissions: 'view_team' - Id: 'efgh' - Permissions: 'create_emojis' -*/ - -DROP PROCEDURE IF EXISTS splitPermissions; -DROP PROCEDURE IF EXISTS sortAndFilterPermissionsInRoles; - -DROP TEMPORARY TABLE IF EXISTS temp_roles; -CREATE TEMPORARY TABLE temp_roles(id varchar(26), permission longtext); - -DELIMITER // - -/* Auxiliary procedure that splits the space-separated permissions string into single rows that are inserted - in the temporary temp_roles table along with their corresponding ID. */ -CREATE PROCEDURE splitPermissions( - IN id varchar(26), - IN permissionsString longtext -) -BEGIN - DECLARE idx INT DEFAULT 0; - SELECT TRIM(permissionsString) INTO permissionsString; - SELECT LOCATE(' ', permissionsString) INTO idx; - WHILE idx > 0 DO - INSERT INTO temp_roles SELECT id, TRIM(LEFT(permissionsString, idx)); - SELECT SUBSTR(permissionsString, idx+1) INTO permissionsString; - SELECT LOCATE(' ', permissionsString) INTO idx; - END WHILE; - INSERT INTO temp_roles(id, permission) VALUES(id, TRIM(permissionsString)); -END; // - -/* Main procedure that does update the Roles table */ -CREATE PROCEDURE sortAndFilterPermissionsInRoles() -BEGIN - DECLARE done INT DEFAULT FALSE; - DECLARE rolesId varchar(26) DEFAULT ''; - DECLARE rolesPermissions longtext DEFAULT ''; - DECLARE cur1 CURSOR FOR SELECT Id, Permissions FROM Roles; - DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; - - /* 1. Set a fixed value in the UpdateAt column for all rows in Roles table */ - UPDATE Roles SET UpdateAt = 1; - - /* Call splitPermissions for every row in the Roles table, thus populating the - temp_roles table. */ - OPEN cur1; - read_loop: LOOP - FETCH cur1 INTO rolesId, rolesPermissions; - IF done THEN - LEAVE read_loop; - END IF; - CALL splitPermissions(rolesId, rolesPermissions); - END LOOP; - CLOSE cur1; - - /* 2. Filter out the new permissions added by the in-app migrations */ - DELETE FROM temp_roles WHERE permission LIKE '%playbook%'; - DELETE FROM temp_roles WHERE permission LIKE 'run_create'; - DELETE FROM temp_roles WHERE permission LIKE 'run_manage_members'; - DELETE FROM temp_roles WHERE permission LIKE 'run_manage_properties'; - DELETE FROM temp_roles WHERE permission LIKE 'run_view'; - - /* Temporarily set to the maximum permitted value, since the call to group_concat - below needs a value bigger than the default */ - SET group_concat_max_len = 18446744073709551615; - - /* 3. Update the Permissions column in the Roles table with the filtered, sorted permissions, - concatenated again as a space-separated string */ - UPDATE - Roles INNER JOIN ( - SELECT temp_roles.id as Id, TRIM(group_concat(temp_roles.permission ORDER BY temp_roles.permission SEPARATOR ' ')) as Permissions - FROM Roles JOIN temp_roles ON Roles.Id = temp_roles.id - GROUP BY temp_roles.id - ) AS Sorted - ON Roles.Id = Sorted.Id - SET Roles.Permissions = Sorted.Permissions; - - /* Reset group_concat_max_len to its default value */ - SET group_concat_max_len = 1024; -END; // -DELIMITER ; - -CALL sortAndFilterPermissionsInRoles(); - -DROP TEMPORARY TABLE IF EXISTS temp_roles; diff --git a/server/scripts/esrupgrades/esr.5.37-6.3.mysql.up.sql b/server/scripts/esrupgrades/esr.5.37-6.3.mysql.up.sql deleted file mode 100644 index 53c1c211fab..00000000000 --- a/server/scripts/esrupgrades/esr.5.37-6.3.mysql.up.sql +++ /dev/null @@ -1,695 +0,0 @@ -/* ==> mysql/000054_create_crt_channelmembership_count.up.sql <== */ -/* fixCRTChannelMembershipCounts fixes the channel counts, i.e. the total message count, -total root message count, mention count, and mention count in root messages for users -who have viewed the channel after the last post in the channel */ - -DELIMITER // -CREATE PROCEDURE MigrateCRTChannelMembershipCounts () -BEGIN - IF( - SELECT - EXISTS ( - SELECT - * FROM Systems - WHERE - Name = 'CRTChannelMembershipCountsMigrationComplete') = 0) THEN - UPDATE - ChannelMembers - INNER JOIN Channels ON Channels.Id = ChannelMembers.ChannelId SET - MentionCount = 0, MentionCountRoot = 0, MsgCount = Channels.TotalMsgCount, MsgCountRoot = Channels.TotalMsgCountRoot, LastUpdateAt = ( - SELECT - (SELECT ROUND(UNIX_TIMESTAMP(NOW(3))*1000))) - WHERE - ChannelMembers.LastViewedAt >= Channels.LastPostAt; - INSERT INTO Systems - VALUES('CRTChannelMembershipCountsMigrationComplete', 'true'); - END IF; -END// -DELIMITER ; -CALL MigrateCRTChannelMembershipCounts (); -DROP PROCEDURE IF EXISTS MigrateCRTChannelMembershipCounts; - -/* ==> mysql/000055_create_crt_thread_count_and_unreads.up.sql <== */ -/* fixCRTThreadCountsAndUnreads Marks threads as read for users where the last -reply time of the thread is earlier than the time the user viewed the channel. -Marking a thread means setting the mention count to zero and setting the -last viewed at time of the the thread as the last viewed at time -of the channel */ - -DELIMITER // -CREATE PROCEDURE MigrateCRTThreadCountsAndUnreads () -BEGIN - IF(SELECT EXISTS(SELECT * FROM Systems WHERE Name = 'CRTThreadCountsAndUnreadsMigrationComplete') = 0) THEN - UPDATE - ThreadMemberships - INNER JOIN ( - SELECT - PostId, - UserId, - ChannelMembers.LastViewedAt AS CM_LastViewedAt, - Threads.LastReplyAt - FROM - Threads - INNER JOIN ChannelMembers ON ChannelMembers.ChannelId = Threads.ChannelId - WHERE - Threads.LastReplyAt <= ChannelMembers.LastViewedAt) AS q ON ThreadMemberships.Postid = q.PostId - AND ThreadMemberships.UserId = q.UserId SET LastViewed = q.CM_LastViewedAt + 1, UnreadMentions = 0, LastUpdated = ( - SELECT - (SELECT ROUND(UNIX_TIMESTAMP(NOW(3))*1000))); - INSERT INTO Systems - VALUES('CRTThreadCountsAndUnreadsMigrationComplete', 'true'); - END IF; -END// -DELIMITER ; -CALL MigrateCRTThreadCountsAndUnreads (); -DROP PROCEDURE IF EXISTS MigrateCRTThreadCountsAndUnreads; - -/* ==> mysql/000056_upgrade_channels_v6.0.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Channels' - AND table_schema = DATABASE() - AND index_name = 'idx_channels_team_id_display_name' - ) > 0, - 'SELECT 1', - 'CREATE INDEX idx_channels_team_id_display_name ON Channels(TeamId, DisplayName);' -)); - -PREPARE createIndexIfNotExists FROM @preparedStatement; -EXECUTE createIndexIfNotExists; -DEALLOCATE PREPARE createIndexIfNotExists; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Channels' - AND table_schema = DATABASE() - AND index_name = 'idx_channels_team_id_type' - ) > 0, - 'SELECT 1', - 'CREATE INDEX idx_channels_team_id_type ON Channels(TeamId, Type);' -)); - -PREPARE createIndexIfNotExists FROM @preparedStatement; -EXECUTE createIndexIfNotExists; -DEALLOCATE PREPARE createIndexIfNotExists; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Channels' - AND table_schema = DATABASE() - AND index_name = 'idx_channels_team_id' - ) > 0, - 'DROP INDEX idx_channels_team_id ON Channels;', - 'SELECT 1' -)); - -PREPARE removeIndexIfExists FROM @preparedStatement; -EXECUTE removeIndexIfExists; -DEALLOCATE PREPARE removeIndexIfExists; - -/* ==> mysql/000057_upgrade_command_webhooks_v6.0.up.sql <== */ - -DELIMITER // -CREATE PROCEDURE MigrateRootId_CommandWebhooks () BEGIN DECLARE ParentId_EXIST INT; -SELECT COUNT(*) -FROM INFORMATION_SCHEMA.COLUMNS -WHERE TABLE_NAME = 'CommandWebhooks' - AND table_schema = DATABASE() - AND COLUMN_NAME = 'ParentId' INTO ParentId_EXIST; -IF(ParentId_EXIST > 0) THEN - UPDATE CommandWebhooks SET RootId = ParentId WHERE RootId = '' AND RootId != ParentId; -END IF; -END// -DELIMITER ; -CALL MigrateRootId_CommandWebhooks (); -DROP PROCEDURE IF EXISTS MigrateRootId_CommandWebhooks; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'CommandWebhooks' - AND table_schema = DATABASE() - AND column_name = 'ParentId' - ) > 0, - 'ALTER TABLE CommandWebhooks DROP COLUMN ParentId;', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -/* ==> mysql/000058_upgrade_channelmembers_v6.0.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'ChannelMembers' - AND table_schema = DATABASE() - AND column_name = 'NotifyProps' - AND column_type != 'JSON' - ) > 0, - 'ALTER TABLE ChannelMembers MODIFY COLUMN NotifyProps JSON;', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'ChannelMembers' - AND table_schema = DATABASE() - AND index_name = 'idx_channelmembers_user_id' - ) > 0, - 'DROP INDEX idx_channelmembers_user_id ON ChannelMembers;', - 'SELECT 1' -)); - -PREPARE removeIndexIfExists FROM @preparedStatement; -EXECUTE removeIndexIfExists; -DEALLOCATE PREPARE removeIndexIfExists; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'ChannelMembers' - AND table_schema = DATABASE() - AND index_name = 'idx_channelmembers_user_id_channel_id_last_viewed_at' - ) > 0, - 'SELECT 1', - 'CREATE INDEX idx_channelmembers_user_id_channel_id_last_viewed_at ON ChannelMembers(UserId, ChannelId, LastViewedAt);' -)); - -PREPARE createIndexIfNotExists FROM @preparedStatement; -EXECUTE createIndexIfNotExists; -DEALLOCATE PREPARE createIndexIfNotExists; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'ChannelMembers' - AND table_schema = DATABASE() - AND index_name = 'idx_channelmembers_channel_id_scheme_guest_user_id' - ) > 0, - 'SELECT 1', - 'CREATE INDEX idx_channelmembers_channel_id_scheme_guest_user_id ON ChannelMembers(ChannelId, SchemeGuest, UserId);' -)); - -PREPARE createIndexIfNotExists FROM @preparedStatement; -EXECUTE createIndexIfNotExists; -DEALLOCATE PREPARE createIndexIfNotExists; - -/* ==> mysql/000059_upgrade_users_v6.0.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Users' - AND table_schema = DATABASE() - AND column_name = 'Props' - AND column_type != 'JSON' - ) > 0, - 'ALTER TABLE Users MODIFY COLUMN Props JSON;', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Users' - AND table_schema = DATABASE() - AND column_name = 'NotifyProps' - AND column_type != 'JSON' - ) > 0, - 'ALTER TABLE Users MODIFY COLUMN NotifyProps JSON;', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Users' - AND table_schema = DATABASE() - AND column_name = 'Timezone' - AND column_default IS NOT NULL - ) > 0, - 'ALTER TABLE Users ALTER Timezone DROP DEFAULT;', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Users' - AND table_schema = DATABASE() - AND column_name = 'Timezone' - AND column_type != 'JSON' - ) > 0, - 'ALTER TABLE Users MODIFY COLUMN Timezone JSON;', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Users' - AND table_schema = DATABASE() - AND column_name = 'Roles' - AND column_type != 'text' - ) > 0, - 'ALTER TABLE Users MODIFY COLUMN Roles text;', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -/* ==> mysql/000060_upgrade_jobs_v6.0.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Jobs' - AND table_schema = DATABASE() - AND column_name = 'Data' - AND column_type != 'JSON' - ) > 0, - 'ALTER TABLE Jobs MODIFY COLUMN Data JSON;', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - - -/* ==> mysql/000061_upgrade_link_metadata_v6.0.up.sql <== */ - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'LinkMetadata' - AND table_schema = DATABASE() - AND column_name = 'Data' - AND column_type != 'JSON' - ) > 0, - 'ALTER TABLE LinkMetadata MODIFY COLUMN Data JSON;', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -/* ==> mysql/000062_upgrade_sessions_v6.0.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Sessions' - AND table_schema = DATABASE() - AND column_name = 'Props' - AND column_type != 'JSON' - ) > 0, - 'ALTER TABLE Sessions MODIFY COLUMN Props JSON;', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - - -/* ==> mysql/000063_upgrade_threads_v6.0.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Threads' - AND table_schema = DATABASE() - AND column_name = 'Participants' - AND column_type != 'JSON' - ) > 0, - 'ALTER TABLE Threads MODIFY COLUMN Participants JSON;', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Threads' - AND table_schema = DATABASE() - AND index_name = 'idx_threads_channel_id_last_reply_at' - ) > 0, - 'SELECT 1', - 'CREATE INDEX idx_threads_channel_id_last_reply_at ON Threads(ChannelId, LastReplyAt);' -)); - -PREPARE createIndexIfNotExists FROM @preparedStatement; -EXECUTE createIndexIfNotExists; -DEALLOCATE PREPARE createIndexIfNotExists; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Threads' - AND table_schema = DATABASE() - AND index_name = 'idx_threads_channel_id' - ) > 0, - 'DROP INDEX idx_threads_channel_id ON Threads;', - 'SELECT 1' -)); - -PREPARE removeIndexIfExists FROM @preparedStatement; -EXECUTE removeIndexIfExists; -DEALLOCATE PREPARE removeIndexIfExists; - -/* ==> mysql/000064_upgrade_status_v6.0.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Status' - AND table_schema = DATABASE() - AND index_name = 'idx_status_status_dndendtime' - ) > 0, - 'SELECT 1', - 'CREATE INDEX idx_status_status_dndendtime ON Status(Status, DNDEndTime);' -)); - -PREPARE createIndexIfNotExists FROM @preparedStatement; -EXECUTE createIndexIfNotExists; -DEALLOCATE PREPARE createIndexIfNotExists; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Status' - AND table_schema = DATABASE() - AND index_name = 'idx_status_status' - ) > 0, - 'DROP INDEX idx_status_status ON Status;', - 'SELECT 1' -)); - -PREPARE removeIndexIfExists FROM @preparedStatement; -EXECUTE removeIndexIfExists; -DEALLOCATE PREPARE removeIndexIfExists; - -/* ==> mysql/000065_upgrade_groupchannels_v6.0.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'GroupChannels' - AND table_schema = DATABASE() - AND index_name = 'idx_groupchannels_schemeadmin' - ) > 0, - 'SELECT 1', - 'CREATE INDEX idx_groupchannels_schemeadmin ON GroupChannels(SchemeAdmin);' -)); - -PREPARE createIndexIfNotExists FROM @preparedStatement; -EXECUTE createIndexIfNotExists; -DEALLOCATE PREPARE createIndexIfNotExists; - -/* ==> mysql/000066_upgrade_posts_v6.0.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigrateRootId_Posts () -BEGIN -DECLARE ParentId_EXIST INT; -DECLARE Alter_FileIds INT; -DECLARE Alter_Props INT; -SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS -WHERE TABLE_NAME = 'Posts' - AND table_schema = DATABASE() - AND COLUMN_NAME = 'ParentId' INTO ParentId_EXIST; -SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Posts' - AND table_schema = DATABASE() - AND column_name = 'FileIds' - AND column_type != 'text' INTO Alter_FileIds; -SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Posts' - AND table_schema = DATABASE() - AND column_name = 'Props' - AND column_type != 'JSON' INTO Alter_Props; -IF (Alter_Props OR Alter_FileIds) THEN - IF(ParentId_EXIST > 0) THEN - UPDATE Posts SET RootId = ParentId WHERE RootId = '' AND RootId != ParentId; - ALTER TABLE Posts MODIFY COLUMN FileIds text, MODIFY COLUMN Props JSON, DROP COLUMN ParentId; - ELSE - ALTER TABLE Posts MODIFY COLUMN FileIds text, MODIFY COLUMN Props JSON; - END IF; -END IF; -END// -DELIMITER ; -CALL MigrateRootId_Posts (); -DROP PROCEDURE IF EXISTS MigrateRootId_Posts; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Posts' - AND table_schema = DATABASE() - AND index_name = 'idx_posts_root_id_delete_at' - ) > 0, - 'SELECT 1', - 'CREATE INDEX idx_posts_root_id_delete_at ON Posts(RootId, DeleteAt);' -)); - -PREPARE createIndexIfNotExists FROM @preparedStatement; -EXECUTE createIndexIfNotExists; -DEALLOCATE PREPARE createIndexIfNotExists; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Posts' - AND table_schema = DATABASE() - AND index_name = 'idx_posts_root_id' - ) > 0, - 'DROP INDEX idx_posts_root_id ON Posts;', - 'SELECT 1' -)); - -PREPARE removeIndexIfExists FROM @preparedStatement; -EXECUTE removeIndexIfExists; -DEALLOCATE PREPARE removeIndexIfExists; - -/* ==> mysql/000067_upgrade_channelmembers_v6.1.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'ChannelMembers' - AND table_schema = DATABASE() - AND column_name = 'Roles' - AND column_type != 'text' - ) > 0, - 'ALTER TABLE ChannelMembers MODIFY COLUMN Roles text;', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -/* ==> mysql/000068_upgrade_teammembers_v6.1.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'TeamMembers' - AND table_schema = DATABASE() - AND column_name = 'Roles' - AND column_type != 'text' - ) > 0, - 'ALTER TABLE TeamMembers MODIFY COLUMN Roles text;', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -/* ==> mysql/000069_upgrade_jobs_v6.1.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Jobs' - AND table_schema = DATABASE() - AND index_name = 'idx_jobs_status_type' - ) > 0, - 'SELECT 1', - 'CREATE INDEX idx_jobs_status_type ON Jobs(Status, Type);' -)); - -PREPARE createIndexIfNotExists FROM @preparedStatement; -EXECUTE createIndexIfNotExists; -DEALLOCATE PREPARE createIndexIfNotExists; - -/* ==> mysql/000070_upgrade_cte_v6.1.up.sql <== */ -DELIMITER // -CREATE PROCEDURE Migrate_LastRootPostAt () -BEGIN -DECLARE - LastRootPostAt_EXIST INT; - SELECT - COUNT(*) - FROM - INFORMATION_SCHEMA.COLUMNS - WHERE - TABLE_NAME = 'Channels' - AND table_schema = DATABASE() - AND COLUMN_NAME = 'LastRootPostAt' INTO LastRootPostAt_EXIST; - IF(LastRootPostAt_EXIST = 0) THEN - ALTER TABLE Channels ADD COLUMN LastRootPostAt bigint DEFAULT 0; - UPDATE - Channels - INNER JOIN ( - SELECT - Channels.Id channelid, - COALESCE(MAX(Posts.CreateAt), 0) AS lastrootpost - FROM - Channels - LEFT JOIN Posts FORCE INDEX (idx_posts_channel_id_update_at) ON Channels.Id = Posts.ChannelId - WHERE - Posts.RootId = '' - GROUP BY - Channels.Id) AS q ON q.channelid = Channels.Id SET LastRootPostAt = lastrootpost; - END IF; -END// -DELIMITER ; -CALL Migrate_LastRootPostAt (); -DROP PROCEDURE IF EXISTS Migrate_LastRootPostAt; - -/* ==> mysql/000071_upgrade_sessions_v6.1.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Sessions' - AND table_schema = DATABASE() - AND column_name = 'Roles' - AND column_type != 'text' - ) > 0, - 'ALTER TABLE Sessions MODIFY COLUMN Roles text;', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -/* ==> mysql/000072_upgrade_schemes_v6.3.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Schemes' - AND table_schema = DATABASE() - AND column_name = 'DefaultPlaybookAdminRole' - ) > 0, - 'SELECT 1', - 'ALTER TABLE Schemes ADD COLUMN DefaultPlaybookAdminRole VARCHAR(64) DEFAULT "";' -)); - -PREPARE alterIfNotExists FROM @preparedStatement; -EXECUTE alterIfNotExists; -DEALLOCATE PREPARE alterIfNotExists; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Schemes' - AND table_schema = DATABASE() - AND column_name = 'DefaultPlaybookMemberRole' - ) > 0, - 'SELECT 1', - 'ALTER TABLE Schemes ADD COLUMN DefaultPlaybookMemberRole VARCHAR(64) DEFAULT "";' -)); - -PREPARE alterIfNotExists FROM @preparedStatement; -EXECUTE alterIfNotExists; -DEALLOCATE PREPARE alterIfNotExists; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Schemes' - AND table_schema = DATABASE() - AND column_name = 'DefaultRunAdminRole' - ) > 0, - 'SELECT 1', - 'ALTER TABLE Schemes ADD COLUMN DefaultRunAdminRole VARCHAR(64) DEFAULT "";' -)); - -PREPARE alterIfNotExists FROM @preparedStatement; -EXECUTE alterIfNotExists; -DEALLOCATE PREPARE alterIfNotExists; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Schemes' - AND table_schema = DATABASE() - AND column_name = 'DefaultRunMemberRole' - ) > 0, - 'SELECT 1', - 'ALTER TABLE Schemes ADD COLUMN DefaultRunMemberRole VARCHAR(64) DEFAULT "";' -)); - -PREPARE alterIfNotExists FROM @preparedStatement; -EXECUTE alterIfNotExists; -DEALLOCATE PREPARE alterIfNotExists; - -/* ==> mysql/000073_upgrade_plugin_key_value_store_v6.3.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT Count(*) FROM Information_Schema.Columns - WHERE table_name = 'PluginKeyValueStore' - AND table_schema = DATABASE() - AND column_name = 'PKey' - AND column_type != 'varchar(150)' - ) > 0, - 'ALTER TABLE PluginKeyValueStore MODIFY COLUMN PKey varchar(150);', - 'SELECT 1' -)); - -PREPARE alterTypeIfExists FROM @preparedStatement; -EXECUTE alterTypeIfExists; -DEALLOCATE PREPARE alterTypeIfExists; - -/* ==> mysql/000074_upgrade_users_v6.3.up.sql <== */ - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Users' - AND table_schema = DATABASE() - AND column_name = 'AcceptedTermsOfServiceId' - ) > 0, - 'ALTER TABLE Users DROP COLUMN AcceptedTermsOfServiceId;', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; diff --git a/server/scripts/esrupgrades/esr.5.37-7.8.mysql.cleanup.sql b/server/scripts/esrupgrades/esr.5.37-7.8.mysql.cleanup.sql deleted file mode 100644 index 4c23874cb12..00000000000 --- a/server/scripts/esrupgrades/esr.5.37-7.8.mysql.cleanup.sql +++ /dev/null @@ -1,199 +0,0 @@ -/* Product notices are controlled externally, via the mattermost/notices repository. - When there is a new notice specified there, the server may have time, right after - the migration and before it is shut down, to download it and modify the - ProductNoticeViewState table, adding a row for all users that have not seen it or - removing old notices that no longer need to be shown. This can happen in the - UpdateProductNotices function that is executed periodically to update the notices - cache. The script will never do this, so we need to remove all rows in that table - to avoid any unwanted diff. */ -DELETE FROM ProductNoticeViewState; - -/* Remove migration-related tables that are only updated through the server to track which - migrations have been applied */ -DROP TABLE IF EXISTS db_lock; -DROP TABLE IF EXISTS db_migrations; - -/* Migration 000054_create_crt_channelmembership_count.up sets - ChannelMembers.LastUpdateAt to the results of SELECT ROUND(UNIX_TIMESTAMP(NOW(3))*1000) - which will be different each time the migration is run. Thus, the column will always be - different when comparing the server and script migrations. To bypass this, we update all - rows in ChannelMembers so that they contain the same value for such column. */ -UPDATE ChannelMembers SET LastUpdateAt = 1; - -/* Migration 000055_create_crt_thread_count_and_unreads.up sets - ThreadMemberships.LastUpdated to the results of SELECT ROUND(UNIX_TIMESTAMP(NOW(3))*1000) - which will be different each time the migration is run. Thus, the column will always be - different when comparing the server and script migrations. To bypass this, we update all - rows in ThreadMemberships so that they contain the same value for such column. */ -UPDATE ThreadMemberships SET LastUpdated = 1; - -/* The security update check in the server may update the LastSecurityTime system value. To - avoid any spurious difference in the migrations, we update it to a fixed value. */ -UPDATE Systems SET Value = 1 WHERE Name = 'LastSecurityTime'; - -/* The server migration may contain a row in the Systems table marking the onboarding as complete. - There are no migrations related to this, so we can simply drop it here. */ -DELETE FROM Systems WHERE Name = 'FirstAdminSetupComplete'; - -/* The server migration contains an in-app migration that adds new roles for Playbooks: - doPlaybooksRolesCreationMigration, defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/migrations.go#L345-L469 - The roles are the ones defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/model/role.go#L874-L929 - When this migration finishes, it also adds a new row to the Systems table with the key of the migration. - This in-app migration does not happen in the script, so we remove those rows here. */ -DELETE FROM Roles WHERE Name = 'playbook_member'; -DELETE FROM Roles WHERE Name = 'playbook_admin'; -DELETE FROM Roles WHERE Name = 'run_member'; -DELETE FROM Roles WHERE Name = 'run_admin'; -DELETE FROM Systems WHERE Name = 'PlaybookRolesCreationMigrationComplete'; - -/* The server migration contains two in-app migrations that add playbooks permissions to certain roles: - getAddPlaybooksPermissions and getPlaybooksPermissionsAddManageRoles, defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/permissions_migrations.go#L1021-L1072 - The specific roles ('%playbook%') are removed in the procedure below, but the migrations also add new rows to the Systems table marking the migrations as complete. - These in-app migrations do not happen in the script, so we remove those rows here. */ -DELETE FROM Systems WHERE Name = 'playbooks_manage_roles'; -DELETE FROM Systems WHERE Name = 'playbooks_permissions'; - -/* The server migration contains an in-app migration that adds boards permissions to certain roles: - getProductsBoardsPermissions, defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/permissions_migrations.go#L1074-L1093 - The specific roles (sysconsole_read_product_boards and sysconsole_write_product_boards) are removed in the procedure below, - but the migrations also adds a new row to the Systems table marking the migrations as complete. - This in-app migration does not happen in the script, so we remove that row here. */ -DELETE FROM Systems WHERE Name = 'products_boards'; - -/* TODO: REVIEW STARTING HERE */ - -/* The server migration contain an in-app migration that adds Ids to the Teams whose InviteId is an empty string: - doRemainingSchemaMigrations, defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/migrations.go#L515-L540 - The migration is not replicated in the script, since it happens in-app, but the server adds a new row to the - Systems table marking the table as complete, which the script doesn't do, so we remove that row here. */ -DELETE FROM Systems WHERE Name = 'RemainingSchemaMigrations'; - -/* The server migration contains three in-app migration that adds a new role and new permissions - related to custom groups. The migrations are: - - doCustomGroupAdminRoleCreationMigration https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/migrations.go#L345-L469 - - getAddCustomUserGroupsPermissions https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/permissions_migrations.go#L974-L995 - - getAddCustomUserGroupsPermissionRestore https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/permissions_migrations.go#L997-L1019 - The specific roles and permissions are removed in the procedure below, but the migrations also - adds a new row to the Roles table for the new role and new rows to the Systems table marking the - migrations as complete. - This in-app migration does not happen in the script, so we remove that row here. */ -DELETE FROM Roles WHERE Name = 'system_custom_group_admin'; -DELETE FROM Systems WHERE Name = 'CustomGroupAdminRoleCreationMigrationComplete'; -DELETE FROM Systems WHERE Name = 'custom_groups_permissions'; -DELETE FROM Systems WHERE Name = 'custom_groups_permission_restore'; - -/* The server migration contains an in-app migration that updates the config, setting ServiceSettings.PostPriority - to true, doPostPriorityConfigDefaultTrueMigration, defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/migrations.go#L542-L560 - The migration is not replicated in the script, since it happens in-app, but the server adds a new row to the - Systems table marking the table as complete, which the script doesn't do, so we remove that row here. */ -DELETE FROM Systems WHERE Name = 'PostPriorityConfigDefaultTrueMigrationComplete'; - -/* The rest of this script defines and executes a procedure to update the Roles table. It performs several changes: - 1. Set the UpdateAt column of all rows to a fixed value, so that the server migration changes to this column - do not appear in the diff. - 2. Remove the set of specific permissions added in the server migration that is not covered by the script, as - this logic happens all in-app after the normal DB migrations. - 3. Set a consistent order in the Permissions column, which is modelled a space-separated string containing each of - the different permissions each role has. This change is the reason why we need a complex procedure, which creates - a temporary table that pairs each single permission to its corresponding ID. So if the Roles table contains two - rows like: - Id: 'abcd' - Permissions: 'view_team read_public_channel invite_user' - Id: 'efgh' - Permissions: 'view_team create_emojis' - then the new temporary table will contain five rows like: - Id: 'abcd' - Permissions: 'view_team' - Id: 'abcd' - Permissions: 'read_public_channel' - Id: 'abcd' - Permissions: 'invite_user' - Id: 'efgh' - Permissions: 'view_team' - Id: 'efgh' - Permissions: 'create_emojis' -*/ - -DROP PROCEDURE IF EXISTS splitPermissions; -DROP PROCEDURE IF EXISTS sortAndFilterPermissionsInRoles; - -DROP TEMPORARY TABLE IF EXISTS temp_roles; -CREATE TEMPORARY TABLE temp_roles(id varchar(26), permission longtext); - -DELIMITER // - -/* Auxiliary procedure that splits the space-separated permissions string into single rows that are inserted - in the temporary temp_roles table along with their corresponding ID. */ -CREATE PROCEDURE splitPermissions( - IN id varchar(26), - IN permissionsString longtext -) -BEGIN - DECLARE idx INT DEFAULT 0; - SELECT TRIM(permissionsString) INTO permissionsString; - SELECT LOCATE(' ', permissionsString) INTO idx; - WHILE idx > 0 DO - INSERT INTO temp_roles SELECT id, TRIM(LEFT(permissionsString, idx)); - SELECT SUBSTR(permissionsString, idx+1) INTO permissionsString; - SELECT LOCATE(' ', permissionsString) INTO idx; - END WHILE; - INSERT INTO temp_roles(id, permission) VALUES(id, TRIM(permissionsString)); -END; // - -/* Main procedure that does update the Roles table */ -CREATE PROCEDURE sortAndFilterPermissionsInRoles() -BEGIN - DECLARE done INT DEFAULT FALSE; - DECLARE rolesId varchar(26) DEFAULT ''; - DECLARE rolesPermissions longtext DEFAULT ''; - DECLARE cur1 CURSOR FOR SELECT Id, Permissions FROM Roles; - DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; - - /* 1. Set a fixed value in the UpdateAt column for all rows in Roles table */ - UPDATE Roles SET UpdateAt = 1; - - /* Call splitPermissions for every row in the Roles table, thus populating the - temp_roles table. */ - OPEN cur1; - read_loop: LOOP - FETCH cur1 INTO rolesId, rolesPermissions; - IF done THEN - LEAVE read_loop; - END IF; - CALL splitPermissions(rolesId, rolesPermissions); - END LOOP; - CLOSE cur1; - - /* 2. Filter out the new permissions added by the in-app migrations */ - DELETE FROM temp_roles WHERE permission LIKE 'sysconsole_read_products_boards'; - DELETE FROM temp_roles WHERE permission LIKE 'sysconsole_write_products_boards'; - DELETE FROM temp_roles WHERE permission LIKE '%playbook%'; - DELETE FROM temp_roles WHERE permission LIKE 'run_create'; - DELETE FROM temp_roles WHERE permission LIKE 'run_manage_members'; - DELETE FROM temp_roles WHERE permission LIKE 'run_manage_properties'; - DELETE FROM temp_roles WHERE permission LIKE 'run_view'; - DELETE FROM temp_roles WHERE permission LIKE '%custom_group%'; - - /* Temporarily set to the maximum permitted value, since the call to group_concat - below needs a value bigger than the default */ - SET group_concat_max_len = 18446744073709551615; - - /* 3. Update the Permissions column in the Roles table with the filtered, sorted permissions, - concatenated again as a space-separated string */ - UPDATE - Roles INNER JOIN ( - SELECT temp_roles.id as Id, TRIM(group_concat(temp_roles.permission ORDER BY temp_roles.permission SEPARATOR ' ')) as Permissions - FROM Roles JOIN temp_roles ON Roles.Id = temp_roles.id - GROUP BY temp_roles.id - ) AS Sorted - ON Roles.Id = Sorted.Id - SET Roles.Permissions = Sorted.Permissions; - - /* Reset group_concat_max_len to its default value */ - SET group_concat_max_len = 1024; -END; // -DELIMITER ; - -CALL sortAndFilterPermissionsInRoles(); - -DROP TEMPORARY TABLE IF EXISTS temp_roles; diff --git a/server/scripts/esrupgrades/esr.5.37-7.8.mysql.up.sql b/server/scripts/esrupgrades/esr.5.37-7.8.mysql.up.sql deleted file mode 100644 index 2c2d271351d..00000000000 --- a/server/scripts/esrupgrades/esr.5.37-7.8.mysql.up.sql +++ /dev/null @@ -1,1391 +0,0 @@ -/* ==> mysql/000041_create_upload_sessions.up.sql <== */ -/* Release 5.37 was meant to contain the index idx_uploadsessions_type, but a bug prevented that. - This part of the migration #41 adds such index */ -/* ==> mysql/000075_alter_upload_sessions_index.up.sql <== */ -/* ==> mysql/000090_create_enums.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigrateUploadSessions () -BEGIN - -- 'CREATE INDEX idx_uploadsessions_type ON UploadSessions(Type);' - DECLARE CreateIndex BOOLEAN; - DECLARE CreateIndexQuery TEXT DEFAULT NULL; - - -- 'DROP INDEX idx_uploadsessions_user_id ON UploadSessions; CREATE INDEX idx_uploadsessions_user_id ON UploadSessions(UserId);' - DECLARE AlterIndex BOOLEAN; - DECLARE AlterIndexQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE UploadSessions MODIFY COLUMN Type ENUM("attachment", "import");' - DECLARE AlterColumn BOOLEAN; - DECLARE AlterColumnQuery TEXT DEFAULT NULL; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'UploadSessions' - AND table_schema = DATABASE() - AND index_name = 'idx_uploadsessions_type' - INTO CreateIndex; - - SELECT IFNULL(GROUP_CONCAT(column_name ORDER BY seq_in_index), '') = 'Type' FROM information_schema.statistics - WHERE table_name = 'UploadSessions' - AND table_schema = DATABASE() - AND index_name = 'idx_uploadsessions_user_id' - GROUP BY index_name - INTO AlterIndex; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'UploadSessions' - AND table_schema = DATABASE() - AND column_name = 'Type' - AND REPLACE(LOWER(column_type), '"', "'") != "enum('attachment','import')" - INTO AlterColumn; - - IF CreateIndex THEN - SET CreateIndexQuery = 'ADD INDEX idx_uploadsessions_type (Type)'; - END IF; - - IF AlterIndex THEN - SET AlterIndexQuery = 'DROP INDEX idx_uploadsessions_user_id, ADD INDEX idx_uploadsessions_user_id (UserId)'; - END IF; - - IF AlterColumn THEN - SET AlterColumnQuery = 'MODIFY COLUMN Type ENUM("attachment", "import")'; - END IF; - - SET @alterQuery = CONCAT_WS(', ', CreateIndexQuery, AlterIndexQuery, AlterColumnQuery); - IF @alterQuery <> '' THEN - SET @query = CONCAT('ALTER TABLE UploadSessions ', @alterQuery); - PREPARE stmt FROM @query; - EXECUTE stmt; - DEALLOCATE PREPARE stmt; - END IF; -END// -DELIMITER ; -SELECT CONCAT('-- ', NOW(), ' MigrateUploadSessions procedure starting.') AS DEBUG; -CALL MigrateUploadSessions(); -SELECT CONCAT('-- ', NOW(), ' MigrateUploadSessions procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigrateUploadSessions; - -/* ==> mysql/000055_create_crt_thread_count_and_unreads.up.sql <== */ -/* fixCRTThreadCountsAndUnreads Marks threads as read for users where the last -reply time of the thread is earlier than the time the user viewed the channel. -Marking a thread means setting the mention count to zero and setting the -last viewed at time of the the thread as the last viewed at time -of the channel */ -DELIMITER // -CREATE PROCEDURE MigrateThreadMemberships () -BEGIN - -- UPDATE ThreadMemberships SET LastViewed = ..., UnreadMentions = ..., LastUpdated = ... - DECLARE UpdateThreadMemberships BOOLEAN; - DECLARE UpdateThreadMembershipsQuery TEXT DEFAULT NULL; - - SELECT COUNT(*) = 0 FROM Systems - WHERE Name = 'CRTThreadCountsAndUnreadsMigrationComplete' - INTO UpdateThreadMemberships; - - IF UpdateThreadMemberships THEN - UPDATE ThreadMemberships INNER JOIN ( - SELECT PostId, UserId, ChannelMembers.LastViewedAt AS CM_LastViewedAt, Threads.LastReplyAt - FROM Threads INNER JOIN ChannelMembers ON ChannelMembers.ChannelId = Threads.ChannelId - WHERE Threads.LastReplyAt <= ChannelMembers.LastViewedAt - ) AS q ON ThreadMemberships.Postid = q.PostId AND ThreadMemberships.UserId = q.UserId - SET LastViewed = q.CM_LastViewedAt + 1, UnreadMentions = 0, LastUpdated = (SELECT (SELECT ROUND(UNIX_TIMESTAMP(NOW(3))*1000))); - INSERT INTO Systems VALUES('CRTThreadCountsAndUnreadsMigrationComplete', 'true'); - END IF; -END// -DELIMITER ; -SELECT CONCAT('-- ', NOW(), ' MigrateThreadMemberships procedure starting.') AS DEBUG; -CALL MigrateThreadMemberships(); -SELECT CONCAT('-- ', NOW(), ' MigrateThreadMemberships procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigrateThreadMemberships; - -/* ==> mysql/000056_upgrade_channels_v6.0.up.sql <== */ -/* ==> mysql/000070_upgrade_cte_v6.1.up.sql <== */ -/* ==> mysql/000090_create_enums.up.sql <== */ -/* ==> mysql/000076_upgrade_lastrootpostat.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigrateChannels () -BEGIN - -- 'DROP INDEX idx_channels_team_id ON Channels;' - DECLARE DropIndex BOOLEAN; - DECLARE DropIndexQuery TEXT DEFAULT NULL; - - -- 'CREATE INDEX idx_channels_team_id_display_name ON Channels(TeamId, DisplayName);' - DECLARE CreateIndexTeamDisplay BOOLEAN; - DECLARE CreateIndexTeamDisplayQuery TEXT DEFAULT NULL; - - -- 'CREATE INDEX idx_channels_team_id_type ON Channels(TeamId, Type);' - DECLARE CreateIndexTeamType BOOLEAN; - DECLARE CreateIndexTeamTypeQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE Channels ADD COLUMN LastRootPostAt bigint DEFAULT 0;'' - -- UPDATE Channels INNER JOIN ... - DECLARE AddLastRootPostAt BOOLEAN; - DECLARE AddLastRootPostAtQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE Channels MODIFY COLUMN Type ENUM("D", "O", "G", "P");', - DECLARE ModifyColumn BOOLEAN; - DECLARE ModifyColumnQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE Channels ALTER COLUMN LastRootPostAt SET DEFAULT 0;', - DECLARE SetDefault BOOLEAN; - DECLARE SetDefaultQuery TEXT DEFAULT NULL; - - -- 'UPDATE Channels SET LastRootPostAt = ...', - DECLARE UpdateLastRootPostAt BOOLEAN; - DECLARE UpdateLastRootPostAtQuery TEXT DEFAULT NULL; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Channels' - AND table_schema = DATABASE() - AND index_name = 'idx_channels_team_id' - INTO DropIndex; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Channels' - AND table_schema = DATABASE() - AND index_name = 'idx_channels_team_id_display_name' - INTO CreateIndexTeamDisplay; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Channels' - AND table_schema = DATABASE() - AND index_name = 'idx_channels_team_id_type' - INTO CreateIndexTeamType; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_NAME = 'Channels' - AND table_schema = DATABASE() - AND COLUMN_NAME = 'LastRootPostAt' - INTO AddLastRootPostAt; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Channels' - AND table_schema = DATABASE() - AND column_name = 'Type' - AND REPLACE(LOWER(column_type), '"', "'") != "enum('d','o','g','p')" - INTO ModifyColumn; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_NAME = 'Channels' - AND TABLE_SCHEMA = DATABASE() - AND COLUMN_NAME = 'LastRootPostAt' - AND (COLUMN_DEFAULT IS NULL OR COLUMN_DEFAULT != 0) - INTO SetDefault; - - IF DropIndex THEN - SET DropIndexQuery = 'DROP INDEX idx_channels_team_id'; - END IF; - - IF CreateIndexTeamDisplay THEN - SET CreateIndexTeamDisplayQuery = 'ADD INDEX idx_channels_team_id_display_name (TeamId, DisplayName)'; - END IF; - - IF CreateIndexTeamType THEN - SET CreateIndexTeamTypeQuery = 'ADD INDEX idx_channels_team_id_type (TeamId, Type)'; - END IF; - - IF AddLastRootPostAt THEN - SET AddLastRootPostAtQuery = 'ADD COLUMN LastRootPostAt bigint DEFAULT 0'; - END IF; - - IF ModifyColumn THEN - SET ModifyColumnQuery = 'MODIFY COLUMN Type ENUM("D", "O", "G", "P")'; - END IF; - - IF SetDefault THEN - SET SetDefaultQuery = 'ALTER COLUMN LastRootPostAt SET DEFAULT 0'; - END IF; - - SET @alterQuery = CONCAT_WS(', ', DropIndexQuery, CreateIndexTeamDisplayQuery, CreateIndexTeamTypeQuery, AddLastRootPostAtQuery, ModifyColumnQuery, SetDefaultQuery); - IF @alterQuery <> '' THEN - SET @query = CONCAT('ALTER TABLE Channels ', @alterQuery); - PREPARE stmt FROM @query; - EXECUTE stmt; - DEALLOCATE PREPARE stmt; - END IF; - - IF AddLastRootPostAt THEN - UPDATE Channels INNER JOIN ( - SELECT Channels.Id channelid, COALESCE(MAX(Posts.CreateAt), 0) AS lastrootpost - FROM Channels LEFT JOIN Posts FORCE INDEX (idx_posts_channel_id_update_at) ON Channels.Id = Posts.ChannelId - WHERE Posts.RootId = '' GROUP BY Channels.Id - ) AS q ON q.channelid = Channels.Id - SET LastRootPostAt = lastrootpost; - END IF; - - -- Cover the case where LastRootPostAt was already present and there are rows with it set to NULL - IF (SELECT COUNT(*) FROM Channels WHERE LastRootPostAt IS NULL) THEN - -- fixes migrate cte and sets the LastRootPostAt for channels that don't have it set - UPDATE Channels INNER JOIN ( - SELECT Channels.Id channelid, COALESCE(MAX(Posts.CreateAt), 0) AS lastrootpost - FROM Channels LEFT JOIN Posts FORCE INDEX (idx_posts_channel_id_update_at) ON Channels.Id = Posts.ChannelId - WHERE Posts.RootId = '' - GROUP BY Channels.Id - ) AS q ON q.channelid = Channels.Id - SET LastRootPostAt = lastrootpost - WHERE LastRootPostAt IS NULL; - -- sets LastRootPostAt to 0, for channels with no posts - UPDATE Channels SET LastRootPostAt=0 WHERE LastRootPostAt IS NULL; - END IF; - -END// -DELIMITER ; - -SELECT CONCAT('-- ', NOW(), ' MigrateChannels procedure starting.') AS DEBUG; -CALL MigrateChannels(); -SELECT CONCAT('-- ', NOW(), ' MigrateChannels procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigrateChannels; - -/* ==> mysql/000057_upgrade_command_webhooks_v6.0.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigrateCommandWebhooks () -BEGIN - DECLARE DropParentId BOOLEAN; - - SELECT COUNT(*) - FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_NAME = 'CommandWebhooks' - AND table_schema = DATABASE() - AND COLUMN_NAME = 'ParentId' - INTO DropParentId; - - IF DropParentId THEN - UPDATE CommandWebhooks SET RootId = ParentId WHERE RootId = '' AND RootId != ParentId; - ALTER TABLE CommandWebhooks DROP COLUMN ParentId; - END IF; -END// -DELIMITER ; -SELECT CONCAT('-- ', NOW(), ' MigrateCommandWebhooks procedure starting.') AS DEBUG; -CALL MigrateCommandWebhooks(); -SELECT CONCAT('-- ', NOW(), ' MigrateCommandWebhooks procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigrateCommandWebhooks; - -/* ==> mysql/000054_create_crt_channelmembership_count.up.sql <== */ -/* ==> mysql/000058_upgrade_channelmembers_v6.0.up.sql <== */ -/* ==> mysql/000067_upgrade_channelmembers_v6.1.up.sql <== */ -/* ==> mysql/000097_create_posts_priority.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigrateChannelMembers () -BEGIN - -- 'ALTER TABLE ChannelMembers MODIFY COLUMN NotifyProps JSON;', - DECLARE ModifyNotifyProps BOOLEAN; - DECLARE ModifyNotifyPropsQuery TEXT DEFAULT NULL; - - -- 'DROP INDEX idx_channelmembers_user_id ON ChannelMembers;', - DECLARE DropIndex BOOLEAN; - DECLARE DropIndexQuery TEXT DEFAULT NULL; - - -- 'CREATE INDEX idx_channelmembers_user_id_channel_id_last_viewed_at ON ChannelMembers(UserId, ChannelId, LastViewedAt);' - DECLARE CreateIndexLastViewedAt BOOLEAN; - DECLARE CreateIndexLastViewedAtQuery TEXT DEFAULT NULL; - - -- 'CREATE INDEX idx_channelmembers_channel_id_scheme_guest_user_id ON ChannelMembers(ChannelId, SchemeGuest, UserId);' - DECLARE CreateIndexSchemeGuest BOOLEAN; - DECLARE CreateIndexSchemeGuestQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE ChannelMembers MODIFY COLUMN Roles text;', - DECLARE ModifyRoles BOOLEAN; - DECLARE ModifyRolesQuery TEXT DEFAULT NOT NULL; - - -- 'ALTER TABLE ChannelMembers ADD COLUMN UrgentMentionCount bigint(20);', - DECLARE AddUrgentMentionCount BOOLEAN; - DECLARE AddUrgentMentionCountQuery TEXT DEFAULT NOT NULL; - - DECLARE MigrateMemberships BOOLEAN; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'ChannelMembers' - AND table_schema = DATABASE() - AND column_name = 'NotifyProps' - AND LOWER(column_type) != 'json' - INTO ModifyNotifyProps; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'ChannelMembers' - AND table_schema = DATABASE() - AND index_name = 'idx_channelmembers_user_id' - INTO DropIndex; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'ChannelMembers' - AND table_schema = DATABASE() - AND index_name = 'idx_channelmembers_user_id_channel_id_last_viewed_at' - INTO CreateIndexLastViewedAt; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'ChannelMembers' - AND table_schema = DATABASE() - AND index_name = 'idx_channelmembers_channel_id_scheme_guest_user_id' - INTO CreateIndexSchemeGuest; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'ChannelMembers' - AND table_schema = DATABASE() - AND column_name = 'Roles' - AND LOWER(column_type) != 'text' - INTO ModifyRoles; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'ChannelMembers' - AND table_schema = DATABASE() - AND column_name = 'UrgentMentionCount' - INTO AddUrgentMentionCount; - - SELECT COUNT(*) = 0 FROM Systems - WHERE Name = 'CRTChannelMembershipCountsMigrationComplete' - INTO MigrateMemberships; - - IF ModifyNotifyProps THEN - SET ModifyNotifyPropsQuery = 'MODIFY COLUMN NotifyProps JSON'; - END IF; - - IF DropIndex THEN - SET DropIndexQuery = 'DROP INDEX idx_channelmembers_user_id'; - END IF; - - IF CreateIndexLastViewedAt THEN - SET CreateIndexLastViewedAtQuery = 'ADD INDEX idx_channelmembers_user_id_channel_id_last_viewed_at (UserId, ChannelId, LastViewedAt)'; - END IF; - - IF CreateIndexSchemeGuest THEN - SET CreateIndexSchemeGuestQuery = 'ADD INDEX idx_channelmembers_channel_id_scheme_guest_user_id (ChannelId, SchemeGuest, UserId)'; - END IF; - - IF ModifyRoles THEN - SET ModifyRolesQuery = 'MODIFY COLUMN Roles text'; - END IF; - - IF AddUrgentMentionCount THEN - SET AddUrgentMentionCountQuery = 'ADD COLUMN UrgentMentionCount bigint(20)'; - END IF; - - SET @alterQuery = CONCAT_WS(', ', ModifyNotifyPropsQuery, DropIndexQuery, CreateIndexLastViewedAtQuery, CreateIndexSchemeGuestQuery, ModifyRolesQuery, AddUrgentMentionCountQuery); - IF @alterQuery <> '' THEN - SET @query = CONCAT('ALTER TABLE ChannelMembers ', @alterQuery); - PREPARE stmt FROM @query; - EXECUTE stmt; - DEALLOCATE PREPARE stmt; - END IF; - - IF MigrateMemberships THEN - UPDATE ChannelMembers INNER JOIN Channels ON Channels.Id = ChannelMembers.ChannelId - SET MentionCount = 0, MentionCountRoot = 0, MsgCount = Channels.TotalMsgCount, MsgCountRoot = Channels.TotalMsgCountRoot, LastUpdateAt = (SELECT (SELECT ROUND(UNIX_TIMESTAMP(NOW(3))*1000))) - WHERE ChannelMembers.LastViewedAt >= Channels.LastPostAt; - INSERT INTO Systems VALUES('CRTChannelMembershipCountsMigrationComplete', 'true'); - END IF; - -END// -DELIMITER ; - -SELECT CONCAT('-- ', NOW(), ' MigrateChannelMembers procedure starting.') AS DEBUG; -CALL MigrateChannelMembers(); -SELECT CONCAT('-- ', NOW(), ' MigrateChannelMembers procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigrateChannelMembers; - -/* ==> mysql/000059_upgrade_users_v6.0.up.sql <== */ -/* ==> mysql/000074_upgrade_users_v6.3.up.sql <== */ -/* ==> mysql/000077_upgrade_users_v6.5.up.sql <== */ -/* ==> mysql/000088_remaining_migrations.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigrateUsers () -BEGIN - -- 'ALTER TABLE Users MODIFY COLUMN Props JSON;', - DECLARE ChangeProps BOOLEAN; - DECLARE ChangePropsQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE Users MODIFY COLUMN NotifyProps JSON;', - DECLARE ChangeNotifyProps BOOLEAN; - DECLARE ChangeNotifyPropsQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE Users ALTER Timezone DROP DEFAULT;', - DECLARE DropTimezoneDefault BOOLEAN; - DECLARE DropTimezoneDefaultQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE Users MODIFY COLUMN Timezone JSON;', - DECLARE ChangeTimezone BOOLEAN; - DECLARE ChangeTimezoneQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE Users MODIFY COLUMN Roles text;', - DECLARE ChangeRoles BOOLEAN; - DECLARE ChangeRolesQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE Users DROP COLUMN AcceptedTermsOfServiceId;', - DECLARE DropTermsOfService BOOLEAN; - DECLARE DropTermsOfServiceQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE Users DROP COLUMN AcceptedServiceTermsId;', - DECLARE DropServiceTerms BOOLEAN; - DECLARE DropServiceTermsQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE Users DROP COLUMN ThemeProps', - DECLARE DropThemeProps BOOLEAN; - DECLARE DropThemePropsQuery TEXT DEFAULT NULL; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Users' - AND table_schema = DATABASE() - AND column_name = 'Props' - AND LOWER(column_type) != 'json' - INTO ChangeProps; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Users' - AND table_schema = DATABASE() - AND column_name = 'NotifyProps' - AND LOWER(column_type) != 'json' - INTO ChangeNotifyProps; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Users' - AND column_default IS NOT NULL - INTO DropTimezoneDefault; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Users' - AND table_schema = DATABASE() - AND column_name = 'Timezone' - AND LOWER(column_type) != 'json' - INTO ChangeTimezone; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Users' - AND table_schema = DATABASE() - AND column_name = 'Roles' - AND LOWER(column_type) != 'text' - INTO ChangeRoles; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Users' - AND table_schema = DATABASE() - AND column_name = 'AcceptedTermsOfServiceId' - INTO DropTermsOfService; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Users' - AND table_schema = DATABASE() - AND column_name = 'AcceptedServiceTermsId' - INTO DropServiceTerms; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Users' - AND table_schema = DATABASE() - AND column_name = 'ThemeProps' - INTO DropThemeProps; - - IF ChangeProps THEN - SET ChangePropsQuery = 'MODIFY COLUMN Props JSON'; - END IF; - - IF ChangeNotifyProps THEN - SET ChangeNotifyPropsQuery = 'MODIFY COLUMN NotifyProps JSON'; - END IF; - - IF DropTimezoneDefault THEN - SET DropTimezoneDefaultQuery = 'ALTER Timezone DROP DEFAULT'; - END IF; - - IF ChangeTimezone THEN - SET ChangeTimezoneQuery = 'MODIFY COLUMN Timezone JSON'; - END IF; - - IF ChangeRoles THEN - SET ChangeRolesQuery = 'MODIFY COLUMN Roles text'; - END IF; - - IF DropTermsOfService THEN - SET DropTermsOfServiceQuery = 'DROP COLUMN AcceptedTermsOfServiceId'; - END IF; - - IF DropServiceTerms THEN - SET DropServiceTermsQuery = 'DROP COLUMN AcceptedServiceTermsId'; - END IF; - - IF DropThemeProps THEN - INSERT INTO Preferences(UserId, Category, Name, Value) SELECT Id, '', '', ThemeProps FROM Users WHERE Users.ThemeProps != 'null'; - SET DropThemePropsQuery = 'DROP COLUMN ThemeProps'; - END IF; - - SET @alterQuery = CONCAT_WS(', ', ChangePropsQuery, ChangeNotifyPropsQuery, DropTimezoneDefaultQuery, ChangeTimezoneQuery, ChangeRolesQuery, DropTermsOfServiceQuery, DropServiceTermsQuery, DropThemePropsQuery); - IF @alterQuery <> '' THEN - SET @query = CONCAT('ALTER TABLE Users ', @alterQuery); - PREPARE stmt FROM @query; - EXECUTE stmt; - DEALLOCATE PREPARE stmt; - END IF; -END// -DELIMITER ; -SELECT CONCAT('-- ', NOW(), ' MigrateUsers procedure starting.') AS DEBUG; -CALL MigrateUsers(); -SELECT CONCAT('-- ', NOW(), ' MigrateUsers procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigrateUsers; - -/* ==> mysql/000060_upgrade_jobs_v6.0.up.sql <== */ -/* ==> mysql/000069_upgrade_jobs_v6.1.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigrateJobs () -BEGIN - -- 'ALTER TABLE Jobs MODIFY COLUMN Data JSON;', - DECLARE ModifyData BOOLEAN; - DECLARE ModifyDataQuery TEXT DEFAULT NULL; - - -- 'CREATE INDEX idx_jobs_status_type ON Jobs(Status, Type);' - DECLARE CreateIndex BOOLEAN; - DECLARE CreateIndexQuery TEXT DEFAULT NULL; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Jobs' - AND table_schema = DATABASE() - AND column_name = 'Data' - AND LOWER(column_type) != 'JSON' - INTO ModifyData; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Jobs' - AND table_schema = DATABASE() - AND index_name = 'idx_jobs_status_type' - INTO CreateIndex; - - IF ModifyData THEN - SET ModifyDataQuery = 'MODIFY COLUMN Data JSON'; - END IF; - - IF CreateIndex THEN - SET CreateIndexQuery = 'ADD INDEX idx_jobs_status_type (Status, Type)'; - END IF; - - SET @alterQuery = CONCAT_WS(', ', ModifyDataQuery, CreateIndexQuery); - IF @alterQuery <> '' THEN - SET @query = CONCAT('ALTER TABLE Jobs ', @alterQuery); - PREPARE stmt FROM @query; - EXECUTE stmt; - DEALLOCATE PREPARE stmt; - END IF; -END// -DELIMITER ; -SELECT CONCAT('-- ', NOW(), ' MigrateJobs procedure starting.') AS DEBUG; -CALL MigrateJobs(); -SELECT CONCAT('-- ', NOW(), ' MigrateJobs procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigrateJobs; - -/* ==> mysql/000061_upgrade_link_metadata_v6.0.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigrateLinkMetadata () -BEGIN - -- ALTER TABLE LinkMetadata MODIFY COLUMN Data JSON; - DECLARE ModifyData BOOLEAN; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'LinkMetadata' - AND table_schema = DATABASE() - AND column_name = 'Data' - AND LOWER(column_type) != 'JSON' - INTO ModifyData; - - IF ModifyData THEN - ALTER TABLE LinkMetadata MODIFY COLUMN Data JSON; - END IF; -END// -DELIMITER ; -SELECT CONCAT('-- ', NOW(), ' MigrateLinkMetadata procedure starting.') AS DEBUG; -CALL MigrateLinkMetadata(); -SELECT CONCAT('-- ', NOW(), ' MigrateLinkMetadata procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigrateLinkMetadata; - -/* ==> mysql/000062_upgrade_sessions_v6.0.up.sql <== */ -/* ==> mysql/000071_upgrade_sessions_v6.1.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigrateSessions () -BEGIN - -- 'ALTER TABLE Sessions MODIFY COLUMN Props JSON;', - DECLARE ModifyProps BOOLEAN; - DECLARE ModifyPropsQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE Sessions MODIFY COLUMN Roles text;', - DECLARE ModifyRoles BOOLEAN; - DECLARE ModifyRolesQuery TEXT DEFAULT NULL; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Sessions' - AND table_schema = DATABASE() - AND column_name = 'Props' - AND LOWER(column_type) != 'json' - INTO ModifyProps; - - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Sessions' - AND table_schema = DATABASE() - AND column_name = 'Roles' - AND LOWER(column_type) != 'text' - INTO ModifyRoles; - - IF ModifyProps THEN - SET ModifyPropsQuery = 'MODIFY COLUMN Props JSON'; - END IF; - - IF ModifyRoles THEN - SET ModifyRolesQuery = 'MODIFY COLUMN Roles text'; - END IF; - - SET @alterQuery = CONCAT_WS(', ', ModifyPropsQuery, ModifyRolesQuery); - IF @alterQuery <> '' THEN - SET @query = CONCAT('ALTER TABLE Sessions ', @alterQuery); - PREPARE stmt FROM @query; - EXECUTE stmt; - DEALLOCATE PREPARE stmt; - END IF; - -END// -DELIMITER ; -SELECT CONCAT('-- ', NOW(), ' MigrateSessions procedure starting.') AS DEBUG; -CALL MigrateSessions(); -SELECT CONCAT('-- ', NOW(), ' MigrateSessions procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigrateSessions; - -/* ==> mysql/000063_upgrade_threads_v6.0.up.sql <== */ -/* ==> mysql/000083_threads_threaddeleteat.up.sql <== */ -/* ==> mysql/000096_threads_threadteamid.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigrateThreads () -BEGIN - -- 'ALTER TABLE Threads MODIFY COLUMN Participants JSON;' - DECLARE ChangeParticipants BOOLEAN; - DECLARE ChangeParticipantsQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE Threads DROP COLUMN DeleteAt;' - DECLARE DropDeleteAt BOOLEAN; - DECLARE DropDeleteAtQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE Threads ADD COLUMN ThreadDeleteAt bigint(20);' - DECLARE CreateThreadDeleteAt BOOLEAN; - DECLARE CreateThreadDeleteAtQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE Threads DROP COLUMN TeamId;' - DECLARE DropTeamId BOOLEAN; - DECLARE DropTeamIdQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE Threads ADD COLUMN ThreadTeamId varchar(26) DEFAULT NULL;' - DECLARE CreateThreadTeamId BOOLEAN; - DECLARE CreateThreadTeamIdQuery TEXT DEFAULT NULL; - - -- CREATE INDEX idx_threads_channel_id_last_reply_at ON Threads(ChannelId, LastReplyAt); - DECLARE CreateIndex BOOLEAN; - DECLARE CreateIndexQuery TEXT DEFAULT NULL; - - -- DROP INDEX idx_threads_channel_id ON Threads; - DECLARE DropIndex BOOLEAN; - DECLARE DropIndexQuery TEXT DEFAULT NULL; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Threads' - AND table_schema = DATABASE() - AND column_name = 'Participants' - AND LOWER(column_type) != 'json' - INTO ChangeParticipants; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Threads' - AND table_schema = DATABASE() - AND column_name = 'DeleteAt' - INTO DropDeleteAt; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Threads' - AND table_schema = DATABASE() - AND column_name = 'ThreadDeleteAt' - INTO CreateThreadDeleteAt; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Threads' - AND table_schema = DATABASE() - AND column_name = 'TeamId' - INTO DropTeamId; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Threads' - AND table_schema = DATABASE() - AND column_name = 'ThreadTeamId' - INTO CreateThreadTeamId; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Threads' - AND table_schema = DATABASE() - AND index_name = 'idx_threads_channel_id_last_reply_at' - INTO CreateIndex; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Threads' - AND table_schema = DATABASE() - AND index_name = 'idx_threads_channel_id' - INTO DropIndex; - - IF ChangeParticipants THEN - SET ChangeParticipantsQuery = 'MODIFY COLUMN Participants JSON'; - END IF; - - IF DropDeleteAt THEN - SET DropDeleteAtQuery = 'DROP COLUMN DeleteAt'; - END IF; - - IF CreateThreadDeleteAt THEN - SET CreateThreadDeleteAtQuery = 'ADD COLUMN ThreadDeleteAt bigint(20)'; - END IF; - - IF DropTeamId THEN - SET DropTeamIdQuery = 'DROP COLUMN TeamId'; - END IF; - - IF CreateThreadTeamId THEN - SET CreateThreadTeamIdQuery = 'ADD COLUMN ThreadTeamId varchar(26) DEFAULT NULL'; - END IF; - - IF CreateIndex THEN - SET CreateIndexQuery = 'ADD INDEX idx_threads_channel_id_last_reply_at (ChannelId, LastReplyAt)'; - END IF; - - IF DropIndex THEN - SET DropIndexQuery = 'DROP INDEX idx_threads_channel_id'; - END IF; - - SET @alterQuery = CONCAT_WS(', ', ChangeParticipantsQuery, DropDeleteAtQuery, CreateThreadDeleteAtQuery, DropTeamIdQuery, CreateThreadTeamIdQuery, CreateIndexQuery, DropIndexQuery); - IF @alterQuery <> '' THEN - SET @query = CONCAT('ALTER TABLE Threads ', @alterQuery); - PREPARE stmt FROM @query; - EXECUTE stmt; - DEALLOCATE PREPARE stmt; - END IF; - - UPDATE Threads, Posts - SET Threads.ThreadDeleteAt = Posts.DeleteAt - WHERE Posts.Id = Threads.PostId - AND Threads.ThreadDeleteAt IS NULL; - - UPDATE Threads, Channels - SET Threads.ThreadTeamId = Channels.TeamId - WHERE Channels.Id = Threads.ChannelId - AND Threads.ThreadTeamId IS NULL; -END// -DELIMITER ; -SELECT CONCAT('-- ', NOW(), ' MigrateThreads procedure starting.') AS DEBUG; -CALL MigrateThreads(); -SELECT CONCAT('-- ', NOW(), ' MigrateThreads procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigrateThreads; - -/* ==> mysql/000064_upgrade_status_v6.0.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigrateStatus () -BEGIN - -- 'CREATE INDEX idx_status_status_dndendtime ON Status(Status, DNDEndTime);' - DECLARE CreateIndex BOOLEAN; - DECLARE CreateIndexQuery TEXT DEFAULT NULL; - - -- 'DROP INDEX idx_status_status ON Status;', - DECLARE DropIndex BOOLEAN; - DECLARE DropIndexQuery TEXT DEFAULT NULL; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Status' - AND table_schema = DATABASE() - AND index_name = 'idx_status_status_dndendtime' - INTO CreateIndex; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Status' - AND table_schema = DATABASE() - AND index_name = 'idx_status_status' - INTO DropIndex; - - IF CreateIndex THEN - SET CreateIndexQuery = 'ADD INDEX idx_status_status_dndendtime (Status, DNDEndTime)'; - END IF; - - IF DropIndex THEN - SET DropIndexQuery = 'DROP INDEX idx_status_status'; - END IF; - - SET @alterQuery = CONCAT_WS(', ', CreateIndexQuery, DropIndexQuery); - IF @alterQuery <> '' THEN - SET @query = CONCAT('ALTER TABLE Status ', @alterQuery); - PREPARE stmt FROM @query; - EXECUTE stmt; - DEALLOCATE PREPARE stmt; - END IF; -END// -DELIMITER ; -SELECT CONCAT('-- ', NOW(), ' MigrateStatus procedure starting.') AS DEBUG; -CALL MigrateStatus (); -SELECT CONCAT('-- ', NOW(), ' MigrateStatus procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigrateStatus; - -/* ==> mysql/000065_upgrade_groupchannels_v6.0.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigrateGroupChannels () -BEGIN - -- 'CREATE INDEX idx_groupchannels_schemeadmin ON GroupChannels(SchemeAdmin);' - DECLARE CreateIndex BOOLEAN; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'GroupChannels' - AND table_schema = DATABASE() - AND index_name = 'idx_groupchannels_schemeadmin' - INTO CreateIndex; - - IF CreateIndex THEN - CREATE INDEX idx_groupchannels_schemeadmin ON GroupChannels(SchemeAdmin); - END IF; -END// -DELIMITER ; -SELECT CONCAT('-- ', NOW(), ' MigrateGroupChannels procedure starting.') AS DEBUG; -CALL MigrateGroupChannels (); -SELECT CONCAT('-- ', NOW(), ' MigrateGroupChannels procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigrateGroupChannels; - -/* ==> mysql/000066_upgrade_posts_v6.0.up.sql <== */ -/* ==> mysql/000080_posts_createat_id.up.sql <== */ -/* ==> mysql/000095_remove_posts_parentid.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigratePosts () -BEGIN - -- DROP COLUMN ParentId - DECLARE DropParentId BOOLEAN; - DECLARE DropParentIdQuery TEXT DEFAULT NULL; - - -- MODIFY COLUMN FileIds - DECLARE ModifyFileIds BOOLEAN; - DECLARE ModifyFileIdsQuery TEXT DEFAULT NULL; - - -- MODIFY COLUMN Props - DECLARE ModifyProps BOOLEAN; - DECLARE ModifyPropsQuery TEXT DEFAULT NULL; - - -- 'CREATE INDEX idx_posts_root_id_delete_at ON Posts(RootId, DeleteAt);' - DECLARE CreateIndexRootId BOOLEAN; - DECLARE CreateIndexRootIdQuery TEXT DEFAULT NULL; - - -- 'DROP INDEX idx_posts_root_id ON Posts;', - DECLARE DropIndex BOOLEAN; - DECLARE DropIndexQuery TEXT DEFAULT NULL; - - -- 'CREATE INDEX idx_posts_create_at_id on Posts(CreateAt, Id) LOCK=NONE;' - DECLARE CreateIndexCreateAt BOOLEAN; - DECLARE CreateIndexCreateAtQuery TEXT DEFAULT NULL; - - -- Condition to control whether to update the RootId column. - DECLARE UpdateRootId BOOLEAN; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_NAME = 'Posts' - AND table_schema = DATABASE() - AND COLUMN_NAME = 'ParentId' - INTO DropParentId; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Posts' - AND table_schema = DATABASE() - AND column_name = 'FileIds' - AND LOWER(column_type) != 'text' - INTO ModifyFileIds; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Posts' - AND table_schema = DATABASE() - AND column_name = 'Props' - AND LOWER(column_type) != 'json' - INTO ModifyProps; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Posts' - AND table_schema = DATABASE() - AND index_name = 'idx_posts_root_id_delete_at' - INTO CreateIndexRootId; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Posts' - AND table_schema = DATABASE() - AND index_name = 'idx_posts_root_id' - INTO DropIndex; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Posts' - AND table_schema = DATABASE() - AND index_name = 'idx_posts_create_at_id' - INTO CreateIndexCreateAt; - - IF DropParentId THEN - SET DropParentIdQuery = 'DROP COLUMN ParentId'; - SELECT COUNT(*) FROM Posts WHERE RootId != ParentId INTO UpdateRootId; - IF UpdateRootId THEN - UPDATE Posts SET RootId = ParentId WHERE RootId = '' AND RootId != ParentId; - END IF; - END IF; - - IF ModifyFileIds THEN - SET ModifyFileIdsQuery = 'MODIFY COLUMN FileIds text'; - END IF; - - IF ModifyProps THEN - SET ModifyPropsQuery = 'MODIFY COLUMN Props JSON'; - END IF; - - IF CreateIndexRootId THEN - SET CreateIndexRootIdQuery = 'ADD INDEX idx_posts_root_id_delete_at (RootId, DeleteAt)'; - END IF; - - IF DropIndex THEN - SET DropIndexQuery = 'DROP INDEX idx_posts_root_id'; - END IF; - - IF CreateIndexCreateAt THEN - SET CreateIndexCreateAtQuery = 'ADD INDEX idx_posts_create_at_id (CreateAt, Id)'; - END IF; - - SET @alterQuery = CONCAT_WS(', ', DropParentIdQuery, ModifyFileIdsQuery, ModifyPropsQuery, CreateIndexRootIdQuery, DropIndexQuery, CreateIndexCreateAtQuery); - IF @alterQuery <> '' THEN - SET @query = CONCAT('ALTER TABLE Posts ', @alterQuery); - PREPARE stmt FROM @query; - EXECUTE stmt; - DEALLOCATE PREPARE stmt; - END IF; - -END// -DELIMITER ; -SELECT CONCAT('-- ', NOW(), ' MigratePosts procedure starting.') AS DEBUG; -CALL MigratePosts (); -SELECT CONCAT('-- ', NOW(), ' MigratePosts procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigratePosts; - -/* ==> mysql/000068_upgrade_teammembers_v6.1.up.sql <== */ -/* ==> mysql/000092_add_createat_to_teammembers.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigrateTeamMembers () -BEGIN - -- 'ALTER TABLE TeamMembers MODIFY COLUMN Roles text;', - DECLARE ModifyRoles BOOLEAN; - DECLARE ModifyRolesQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE TeamMembers ADD COLUMN CreateAt bigint DEFAULT 0;', - DECLARE AddCreateAt BOOLEAN; - DECLARE AddCreateAtQuery TEXT DEFAULT NULL; - - -- 'CREATE INDEX idx_teammembers_createat ON TeamMembers(CreateAt);' - DECLARE CreateIndex BOOLEAN; - DECLARE CreateIndexQuery TEXT DEFAULT NULL; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'TeamMembers' - AND table_schema = DATABASE() - AND column_name = 'Roles' - AND LOWER(column_type) != 'text' - INTO ModifyRoles; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'TeamMembers' - AND table_schema = DATABASE() - AND column_name = 'CreateAt' - INTO AddCreateAt; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'TeamMembers' - AND table_schema = DATABASE() - AND index_name = 'idx_teammembers_createat' - INTO CreateIndex; - - IF ModifyRoles THEN - SET ModifyRolesQuery = 'MODIFY COLUMN Roles text'; - END IF; - - IF AddCreateAt THEN - SET AddCreateAtQuery = 'ADD COLUMN CreateAt bigint DEFAULT 0'; - END IF; - - IF CreateIndex THEN - SET CreateIndexQuery = 'ADD INDEX idx_teammembers_createat (CreateAt)'; - END IF; - - SET @alterQuery = CONCAT_WS(', ', ModifyRolesQuery, AddCreateAtQuery, CreateIndexQuery); - IF @alterQuery <> '' THEN - SET @query = CONCAT('ALTER TABLE TeamMembers ', @alterQuery); - PREPARE stmt FROM @query; - EXECUTE stmt; - DEALLOCATE PREPARE stmt; - END IF; -END// -DELIMITER ; -SELECT CONCAT('-- ', NOW(), ' MigrateTeamMembers procedure starting.') AS DEBUG; -CALL MigrateTeamMembers (); -SELECT CONCAT('-- ', NOW(), ' MigrateTeamMembers procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigrateTeamMembers; - -/* ==> mysql/000072_upgrade_schemes_v6.3.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigrateSchemes () -BEGIN - -- 'ALTER TABLE Schemes ADD COLUMN DefaultPlaybookAdminRole VARCHAR(64) DEFAULT "";' - DECLARE AddDefaultPlaybookAdminRole BOOLEAN; - DECLARE AddDefaultPlaybookAdminRoleQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE Schemes ADD COLUMN DefaultPlaybookMemberRole VARCHAR(64) DEFAULT "";' - DECLARE AddDefaultPlaybookMemberRole BOOLEAN; - DECLARE AddDefaultPlaybookMemberRoleQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE Schemes ADD COLUMN DefaultRunAdminRole VARCHAR(64) DEFAULT "";' - DECLARE AddDefaultRunAdminRole BOOLEAN; - DECLARE AddDefaultRunAdminRoleQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE Schemes ADD COLUMN DefaultRunMemberRole VARCHAR(64) DEFAULT "";' - DECLARE AddDefaultRunMemberRole BOOLEAN; - DECLARE AddDefaultRunMemberRoleQuery TEXT DEFAULT NULL; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Schemes' - AND table_schema = DATABASE() - AND column_name = 'DefaultPlaybookAdminRole' - INTO AddDefaultPlaybookAdminRole; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Schemes' - AND table_schema = DATABASE() - AND column_name = 'DefaultPlaybookMemberRole' - INTO AddDefaultPlaybookMemberRole; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Schemes' - AND table_schema = DATABASE() - AND column_name = 'DefaultRunAdminRole' - INTO AddDefaultRunAdminRole; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Schemes' - AND table_schema = DATABASE() - AND column_name = 'DefaultRunMemberRole' - INTO AddDefaultRunMemberRole; - - IF AddDefaultPlaybookAdminRole THEN - SET AddDefaultPlaybookAdminRoleQuery = 'ADD COLUMN DefaultPlaybookAdminRole VARCHAR(64) DEFAULT ""'; - END IF; - - IF AddDefaultPlaybookMemberRole THEN - SET AddDefaultPlaybookMemberRoleQuery = 'ADD COLUMN DefaultPlaybookMemberRole VARCHAR(64) DEFAULT ""'; - END IF; - - IF AddDefaultRunAdminRole THEN - SET AddDefaultRunAdminRoleQuery = 'ADD COLUMN DefaultRunAdminRole VARCHAR(64) DEFAULT ""'; - END IF; - - IF AddDefaultRunMemberRole THEN - SET AddDefaultRunMemberRoleQuery = 'ADD COLUMN DefaultRunMemberRole VARCHAR(64) DEFAULT ""'; - END IF; - - SET @alterQuery = CONCAT_WS(', ', AddDefaultPlaybookAdminRoleQuery, AddDefaultPlaybookMemberRoleQuery, AddDefaultRunAdminRoleQuery, AddDefaultRunMemberRoleQuery); - IF @alterQuery <> '' THEN - SET @query = CONCAT('ALTER TABLE Schemes ', @alterQuery); - PREPARE stmt FROM @query; - EXECUTE stmt; - DEALLOCATE PREPARE stmt; - END IF; -END// -DELIMITER ; -SELECT CONCAT('-- ', NOW(), ' MigrateSchemes procedure starting.') AS DEBUG; -CALL MigrateSchemes (); -SELECT CONCAT('-- ', NOW(), ' MigrateSchemes procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigrateSchemes; - -/* ==> mysql/000073_upgrade_plugin_key_value_store_v6.3.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigratePluginKeyValueStore () -BEGIN - -- 'ALTER TABLE PluginKeyValueStore MODIFY COLUMN PKey varchar(150);', - DECLARE ModifyPKey BOOLEAN; - - SELECT COUNT(*) FROM Information_Schema.Columns - WHERE table_name = 'PluginKeyValueStore' - AND table_schema = DATABASE() - AND column_name = 'PKey' - AND LOWER(column_type) != 'varchar(150)' - INTO ModifyPKey; - - IF ModifyPKey THEN - ALTER TABLE PluginKeyValueStore MODIFY COLUMN PKey varchar(150); - END IF; -END// -DELIMITER ; -SELECT CONCAT('-- ', NOW(), ' MigratePluginKeyValueStore procedure starting.') AS DEBUG; -CALL MigratePluginKeyValueStore (); -SELECT CONCAT('-- ', NOW(), ' MigratePluginKeyValueStore procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigratePluginKeyValueStore; - -/* ==> mysql/000078_create_oauth_mattermost_app_id.up.sql <== */ -/* ==> mysql/000082_upgrade_oauth_mattermost_app_id.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigrateOAuthApps () -BEGIN - -- 'ALTER TABLE OAuthApps ADD COLUMN MattermostAppID varchar(32);' - DECLARE AddMattermostAppID BOOLEAN; - DECLARE AddMattermostAppIDQuery TEXT DEFAULT NULL; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'OAuthApps' - AND table_schema = DATABASE() - AND column_name = 'MattermostAppID' - INTO AddMattermostAppID; - - IF AddMattermostAppID THEN - SET AddMattermostAppIDQuery = 'ADD COLUMN MattermostAppID varchar(32) NOT NULL DEFAULT ""'; - SET @query = CONCAT('ALTER TABLE OAuthApps ', CONCAT_WS(', ', AddMattermostAppIDQuery)); - PREPARE stmt FROM @query; - EXECUTE stmt; - DEALLOCATE PREPARE stmt; - END IF; - - IF AddMattermostAppID THEN - UPDATE OAuthApps SET MattermostAppID = "" WHERE MattermostAppID IS NULL; - END IF; -END// -DELIMITER ; -SELECT CONCAT('-- ', NOW(), ' MigrateOAuthApps procedure starting.') AS DEBUG; -CALL MigrateOAuthApps (); -SELECT CONCAT('-- ', NOW(), ' MigrateOAuthApps procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigrateOAuthApps; - -/* ==> mysql/000079_usergroups_displayname_index.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigrateUserGroups () -BEGIN - -- 'CREATE INDEX idx_usergroups_displayname ON UserGroups(DisplayName);' - DECLARE CreateIndex BOOLEAN; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'UserGroups' - AND table_schema = DATABASE() - AND index_name = 'idx_usergroups_displayname' - INTO CreateIndex; - - IF CreateIndex THEN - CREATE INDEX idx_usergroups_displayname ON UserGroups(DisplayName); - END IF; -END// -DELIMITER ; -SELECT CONCAT('-- ', NOW(), ' MigrateUserGroups procedure starting.') AS DEBUG; -CALL MigrateUserGroups (); -SELECT CONCAT('-- ', NOW(), ' MigrateUserGroups procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigrateUserGroups; - -/* ==> mysql/000081_threads_deleteat.up.sql <== */ --- Replaced by 000083_threads_threaddeleteat.up.sql - -/* ==> mysql/000084_recent_searches.up.sql <== */ -CREATE TABLE IF NOT EXISTS RecentSearches ( - UserId CHAR(26), - SearchPointer int, - Query json, - CreateAt bigint NOT NULL, - PRIMARY KEY (UserId, SearchPointer) -); - -/* ==> mysql/000085_fileinfo_add_archived_column.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigrateFileInfo () -BEGIN - -- 'ALTER TABLE FileInfo ADD COLUMN Archived boolean NOT NULL DEFAULT false;' - DECLARE AddArchived BOOLEAN; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'FileInfo' - AND table_schema = DATABASE() - AND column_name = 'Archived' - INTO AddArchived; - - IF AddArchived THEN - ALTER TABLE FileInfo ADD COLUMN Archived boolean NOT NULL DEFAULT false; - END IF; -END// -DELIMITER ; -SELECT CONCAT('-- ', NOW(), ' MigrateFileInfo procedure starting.') AS DEBUG; -CALL MigrateFileInfo (); -SELECT CONCAT('-- ', NOW(), ' MigrateFileInfo procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigrateFileInfo; - -/* ==> mysql/000086_add_cloud_limits_archived.up.sql <== */ -/* ==> mysql/000090_create_enums.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigrateTeams () -BEGIN - -- 'ALTER TABLE Teams ADD COLUMN CloudLimitsArchived BOOLEAN NOT NULL DEFAULT FALSE;', - DECLARE AddCloudLimitsArchived BOOLEAN; - DECLARE AddCloudLimitsArchivedQuery TEXT DEFAULT NULL; - - -- 'ALTER TABLE Teams MODIFY COLUMN Type ENUM("I", "O");', - DECLARE ModifyType BOOLEAN; - DECLARE ModifyTypeQuery TEXT DEFAULT NULL; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Teams' - AND table_schema = DATABASE() - AND column_name = 'CloudLimitsArchived' - INTO AddCloudLimitsArchived; - - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Teams' - AND table_schema = DATABASE() - AND column_name = 'Type' - AND REPLACE(LOWER(column_type), '"', "'") != "enum('i','o')" - INTO ModifyType; - - IF AddCloudLimitsArchived THEN - SET AddCloudLimitsArchivedQuery = 'ADD COLUMN CloudLimitsArchived BOOLEAN NOT NULL DEFAULT FALSE'; - END IF; - - IF ModifyType THEN - SET ModifyTypeQuery = 'MODIFY COLUMN Type ENUM("I", "O")'; - END IF; - - SET @alterQuery = CONCAT_WS(', ', AddCloudLimitsArchivedQuery, ModifyTypeQuery); - IF @alterQuery <> '' THEN - SET @query = CONCAT('ALTER TABLE Teams ', @alterQuery); - PREPARE stmt FROM @query; - EXECUTE stmt; - DEALLOCATE PREPARE stmt; - END IF; -END// -DELIMITER ; -SELECT CONCAT('-- ', NOW(), ' MigrateTeams procedure starting.') AS DEBUG; -CALL MigrateTeams (); -SELECT CONCAT('-- ', NOW(), ' MigrateTeams procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigrateTeams; - -/* ==> mysql/000087_sidebar_categories_index.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigrateSidebarCategories () -BEGIN - -- 'CREATE INDEX idx_sidebarcategories_userid_teamid on SidebarCategories(UserId, TeamId) LOCK=NONE;' - DECLARE CreateIndex BOOLEAN; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'SidebarCategories' - AND table_schema = DATABASE() - AND index_name = 'idx_sidebarcategories_userid_teamid' - INTO CreateIndex; - - IF CreateIndex THEN - CREATE INDEX idx_sidebarcategories_userid_teamid on SidebarCategories(UserId, TeamId) LOCK=NONE; - END IF; -END// -DELIMITER ; -SELECT CONCAT('-- ', NOW(), ' MigrateSidebarCategories procedure starting.') AS DEBUG; -CALL MigrateSidebarCategories (); -SELECT CONCAT('-- ', NOW(), ' MigrateSidebarCategories procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigrateSidebarCategories; - -/* ==> mysql/000088_remaining_migrations.up.sql <== */ -DROP TABLE IF EXISTS JobStatuses; -DROP TABLE IF EXISTS PasswordRecovery; - -/* ==> mysql/000089_add-channelid-to-reaction.up.sql <== */ -DELIMITER // -CREATE PROCEDURE MigrateReactions () -BEGIN - -- 'ALTER TABLE Reactions ADD COLUMN ChannelId varchar(26) NOT NULL DEFAULT "";', - DECLARE AddChannelId BOOLEAN; - DECLARE AddChannelIdQuery TEXT DEFAULT NULL; - - -- 'CREATE INDEX idx_reactions_channel_id ON Reactions(ChannelId);' - DECLARE CreateIndex BOOLEAN; - DECLARE CreateIndexQuery TEXT DEFAULT NULL; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Reactions' - AND table_schema = DATABASE() - AND column_name = 'ChannelId' - INTO AddChannelId; - - SELECT COUNT(*) = 0 FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Reactions' - AND table_schema = DATABASE() - AND index_name = 'idx_reactions_channel_id' - INTO CreateIndex; - - IF AddChannelId THEN - SET AddChannelIdQuery = 'ADD COLUMN ChannelId varchar(26) NOT NULL DEFAULT ""'; - END IF; - - IF CreateIndex THEN - SET CreateIndexQuery = 'ADD INDEX idx_reactions_channel_id (ChannelId)'; - END IF; - - SET @alterQuery = CONCAT_WS(', ', AddChannelIdQuery, CreateIndexQuery); - IF @alterQuery <> '' THEN - SET @query = CONCAT('ALTER TABLE Reactions ', @alterQuery); - PREPARE stmt FROM @query; - EXECUTE stmt; - DEALLOCATE PREPARE stmt; - END IF; - - UPDATE Reactions SET ChannelId = COALESCE((select ChannelId from Posts where Posts.Id = Reactions.PostId), '') WHERE ChannelId=""; -END// -DELIMITER ; -SELECT CONCAT('-- ', NOW(), ' MigrateReactions procedure starting.') AS DEBUG; -CALL MigrateReactions (); -SELECT CONCAT('-- ', NOW(), ' MigrateReactions procedure finished.') AS DEBUG; -DROP PROCEDURE IF EXISTS MigrateReactions; - -/* ==> mysql/000091_create_post_reminder.up.sql <== */ -CREATE TABLE IF NOT EXISTS PostReminders ( - PostId varchar(26) NOT NULL, - UserId varchar(26) NOT NULL, - TargetTime bigint, - INDEX idx_postreminders_targettime (TargetTime), - PRIMARY KEY (PostId, UserId) -); - -/* ==> mysql/000093_notify_admin.up.sql <== */ -CREATE TABLE IF NOT EXISTS NotifyAdmin ( - UserId varchar(26) NOT NULL, - CreateAt bigint(20) DEFAULT NULL, - RequiredPlan varchar(26) NOT NULL, - RequiredFeature varchar(100) NOT NULL, - Trial BOOLEAN NOT NULL, - PRIMARY KEY (UserId, RequiredFeature, RequiredPlan) -); - -/* ==> mysql/000094_threads_teamid.up.sql <== */ --- Replaced by 000096_threads_threadteamid.up.sql - -/* ==> mysql/000097_create_posts_priority.up.sql <== */ -CREATE TABLE IF NOT EXISTS PostsPriority ( - PostId varchar(26) NOT NULL, - ChannelId varchar(26) NOT NULL, - Priority varchar(32) NOT NULL, - RequestedAck tinyint(1), - PersistentNotifications tinyint(1), - PRIMARY KEY (PostId) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -/* ==> mysql/000098_create_post_acknowledgements.up.sql <== */ -CREATE TABLE IF NOT EXISTS PostAcknowledgements ( - PostId varchar(26) NOT NULL, - UserId varchar(26) NOT NULL, - AcknowledgedAt bigint(20) DEFAULT NULL, - PRIMARY KEY (PostId, UserId) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -/* ==> mysql/000099_create_drafts.up.sql <== */ -/* ==> mysql/000100_add_draft_priority_column.up.sql <== */ -CREATE TABLE IF NOT EXISTS Drafts ( - CreateAt bigint(20) DEFAULT NULL, - UpdateAt bigint(20) DEFAULT NULL, - DeleteAt bigint(20) DEFAULT NULL, - UserId varchar(26) NOT NULL, - ChannelId varchar(26) NOT NULL, - RootId varchar(26) DEFAULT '', - Message text, - Props text, - FileIds text, - Priority text, - PRIMARY KEY (UserId, ChannelId, RootId) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -/* ==> mysql/000101_create_true_up_review_history.up.sql <== */ -CREATE TABLE IF NOT EXISTS TrueUpReviewHistory ( - DueDate bigint(20), - Completed boolean, - PRIMARY KEY (DueDate) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/scripts/esrupgrades/esr.6.3-7.8.mysql.cleanup.sql b/server/scripts/esrupgrades/esr.6.3-7.8.mysql.cleanup.sql deleted file mode 100644 index 43af4c48445..00000000000 --- a/server/scripts/esrupgrades/esr.6.3-7.8.mysql.cleanup.sql +++ /dev/null @@ -1,168 +0,0 @@ -/* Product notices are controlled externally, via the mattermost/notices repository. - When there is a new notice specified there, the server may have time, right after - the migration and before it is shut down, to download it and modify the - ProductNoticeViewState table, adding a row for all users that have not seen it or - removing old notices that no longer need to be shown. This can happen in the - UpdateProductNotices function that is executed periodically to update the notices - cache. The script will never do this, so we need to remove all rows in that table - to avoid any unwanted diff. */ -DELETE FROM ProductNoticeViewState; - -/* Remove migration-related tables that are only updated through the server to track which - migrations have been applied */ -DROP TABLE IF EXISTS db_lock; -DROP TABLE IF EXISTS db_migrations; - -/* The security update check in the server may update the LastSecurityTime system value. To - avoid any spurious difference in the migrations, we update it to a fixed value. */ -UPDATE Systems SET Value = 1 WHERE Name = 'LastSecurityTime'; - -/* The server migration may contain a row in the Systems table marking the onboarding as complete. - There are no migrations related to this, so we can simply drop it here. */ -DELETE FROM Systems WHERE Name = 'FirstAdminSetupComplete'; - -/* The server migration contains an in-app migration that add playbooks permissions to certain roles: - getPlaybooksPermissionsAddManageRoles, defined in https://github.com/mattermost/mattermost-server/blob/56a093ceaee6389a01a35b6d4626ef5a9fea4759/app/permissions_migrations.go#L1056-L1072 - The specific roles ('%playbook%') are removed in the procedure below, but the migrations also add new rows to the Systems table marking the migrations as complete. - This in-app migration does not happen in the script, so we remove that rows here. */ -DELETE FROM Systems WHERE Name = 'playbooks_manage_roles'; - -/* The server migration contains an in-app migration that adds boards permissions to certain roles: - getProductsBoardsPermissions, defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/permissions_migrations.go#L1074-L1093 - The specific roles (sysconsole_read_product_boards and sysconsole_write_product_boards) are removed in the procedure below, - but the migrations also adds a new row to the Systems table marking the migrations as complete. - This in-app migration does not happen in the script, so we remove that row here. */ -DELETE FROM Systems WHERE Name = 'products_boards'; - -/* The server migration contains an in-app migration that adds Ids to the Teams whose InviteId is an empty string: - doRemainingSchemaMigrations, defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/migrations.go#L515-L540 - The migration is not replicated in the script, since it happens in-app, but the server adds a new row to the - Systems table marking the table as complete, which the script doesn't do, so we remove that row here. */ -DELETE FROM Systems WHERE Name = 'RemainingSchemaMigrations'; - -/* The server migration contains three in-app migration that adds a new role and new permissions - related to custom groups. The migrations are: - - doCustomGroupAdminRoleCreationMigration https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/migrations.go#L345-L469 - - getAddCustomUserGroupsPermissions https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/permissions_migrations.go#L974-L995 - - getAddCustomUserGroupsPermissionRestore https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/permissions_migrations.go#L997-L1019 - The specific roles and permissions are removed in the procedure below, but the migrations also - adds a new row to the Roles table for the new role and new rows to the Systems table marking the - migrations as complete. - This in-app migration does not happen in the script, so we remove that row here. */ -DELETE FROM Roles WHERE Name = 'system_custom_group_admin'; -DELETE FROM Systems WHERE Name = 'CustomGroupAdminRoleCreationMigrationComplete'; -DELETE FROM Systems WHERE Name = 'custom_groups_permissions'; -DELETE FROM Systems WHERE Name = 'custom_groups_permission_restore'; - -/* The server migration contains an in-app migration that updates the config, setting ServiceSettings.PostPriority - to true, doPostPriorityConfigDefaultTrueMigration, defined in https://github.com/mattermost/mattermost-server/blob/282bd351e3767dcfd8c8340da2e0915197c0dbcb/app/migrations.go#L542-L560 - The migration is not replicated in the script, since it happens in-app, but the server adds a new row to the - Systems table marking the table as complete, which the script doesn't do, so we remove that row here. */ -DELETE FROM Systems WHERE Name = 'PostPriorityConfigDefaultTrueMigrationComplete'; - -/* The rest of this script defines and executes a procedure to update the Roles table. It performs several changes: - 1. Set the UpdateAt column of all rows to a fixed value, so that the server migration changes to this column - do not appear in the diff. - 2. Remove the set of specific permissions added in the server migration that is not covered by the script, as - this logic happens all in-app after the normal DB migrations. - 3. Set a consistent order in the Permissions column, which is modelled a space-separated string containing each of - the different permissions each role has. This change is the reason why we need a complex procedure, which creates - a temporary table that pairs each single permission to its corresponding ID. So if the Roles table contains two - rows like: - Id: 'abcd' - Permissions: 'view_team read_public_channel invite_user' - Id: 'efgh' - Permissions: 'view_team create_emojis' - then the new temporary table will contain five rows like: - Id: 'abcd' - Permissions: 'view_team' - Id: 'abcd' - Permissions: 'read_public_channel' - Id: 'abcd' - Permissions: 'invite_user' - Id: 'efgh' - Permissions: 'view_team' - Id: 'efgh' - Permissions: 'create_emojis' -*/ - -DROP PROCEDURE IF EXISTS splitPermissions; -DROP PROCEDURE IF EXISTS sortAndFilterPermissionsInRoles; - -DROP TEMPORARY TABLE IF EXISTS temp_roles; -CREATE TEMPORARY TABLE temp_roles(id varchar(26), permission longtext); - -DELIMITER // - -/* Auxiliary procedure that splits the space-separated permissions string into single rows that are inserted - in the temporary temp_roles table along with their corresponding ID. */ -CREATE PROCEDURE splitPermissions( - IN id varchar(26), - IN permissionsString longtext -) -BEGIN - DECLARE idx INT DEFAULT 0; - SELECT TRIM(permissionsString) INTO permissionsString; - SELECT LOCATE(' ', permissionsString) INTO idx; - WHILE idx > 0 DO - INSERT INTO temp_roles SELECT id, TRIM(LEFT(permissionsString, idx)); - SELECT SUBSTR(permissionsString, idx+1) INTO permissionsString; - SELECT LOCATE(' ', permissionsString) INTO idx; - END WHILE; - INSERT INTO temp_roles(id, permission) VALUES(id, TRIM(permissionsString)); -END; // - -/* Main procedure that does update the Roles table */ -CREATE PROCEDURE sortAndFilterPermissionsInRoles() -BEGIN - DECLARE done INT DEFAULT FALSE; - DECLARE rolesId varchar(26) DEFAULT ''; - DECLARE rolesPermissions longtext DEFAULT ''; - DECLARE cur1 CURSOR FOR SELECT Id, Permissions FROM Roles; - DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; - - /* 1. Set a fixed value in the UpdateAt column for all rows in Roles table */ - UPDATE Roles SET UpdateAt = 1; - - /* Call splitPermissions for every row in the Roles table, thus populating the - temp_roles table. */ - OPEN cur1; - read_loop: LOOP - FETCH cur1 INTO rolesId, rolesPermissions; - IF done THEN - LEAVE read_loop; - END IF; - CALL splitPermissions(rolesId, rolesPermissions); - END LOOP; - CLOSE cur1; - - /* 2. Filter out the new permissions added by the in-app migrations */ - DELETE FROM temp_roles WHERE permission LIKE 'sysconsole_read_products_boards'; - DELETE FROM temp_roles WHERE permission LIKE 'sysconsole_write_products_boards'; - DELETE FROM temp_roles WHERE permission LIKE 'playbook_public_manage_roles'; - DELETE FROM temp_roles WHERE permission LIKE 'playbook_private_manage_roles'; - DELETE FROM temp_roles WHERE permission LIKE '%custom_group%'; - - /* Temporarily set to the maximum permitted value, since the call to group_concat - below needs a value bigger than the default */ - SET group_concat_max_len = 18446744073709551615; - - /* 3. Update the Permissions column in the Roles table with the filtered, sorted permissions, - concatenated again as a space-separated string */ - UPDATE - Roles INNER JOIN ( - SELECT temp_roles.id as Id, TRIM(group_concat(temp_roles.permission ORDER BY temp_roles.permission SEPARATOR ' ')) as Permissions - FROM Roles JOIN temp_roles ON Roles.Id = temp_roles.id - GROUP BY temp_roles.id - ) AS Sorted - ON Roles.Id = Sorted.Id - SET Roles.Permissions = Sorted.Permissions; - - /* Reset group_concat_max_len to its default value */ - SET group_concat_max_len = 1024; -END; // -DELIMITER ; - -CALL sortAndFilterPermissionsInRoles(); - -DROP TEMPORARY TABLE IF EXISTS temp_roles; diff --git a/server/scripts/esrupgrades/esr.6.3-7.8.mysql.up.sql b/server/scripts/esrupgrades/esr.6.3-7.8.mysql.up.sql deleted file mode 100644 index 572224e1a12..00000000000 --- a/server/scripts/esrupgrades/esr.6.3-7.8.mysql.up.sql +++ /dev/null @@ -1,599 +0,0 @@ -/* ==> mysql/000041_create_upload_sessions.up.sql <== */ -/* Release 5.37 was meant to contain the index idx_uploadsessions_type, but a bug prevented that. - This part of the migration #41 adds such index */ - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'UploadSessions' - AND table_schema = DATABASE() - AND index_name = 'idx_uploadsessions_type' - ) > 0, - 'SELECT 1', - 'CREATE INDEX idx_uploadsessions_type ON UploadSessions(Type);' -)); - -PREPARE createIndexIfNotExists FROM @preparedStatement; -EXECUTE createIndexIfNotExists; -DEALLOCATE PREPARE createIndexIfNotExists; - -/* ==> mysql/000075_alter_upload_sessions_index.up.sql <== */ -DELIMITER // -CREATE PROCEDURE AlterIndex() -BEGIN - DECLARE columnName varchar(26) default ''; - - SELECT IFNULL(GROUP_CONCAT(column_name ORDER BY seq_in_index), '') INTO columnName - FROM information_schema.statistics - WHERE table_schema = DATABASE() - AND table_name = 'UploadSessions' - AND index_name = 'idx_uploadsessions_user_id' - GROUP BY index_name; - - IF columnName = 'Type' THEN - DROP INDEX idx_uploadsessions_user_id ON UploadSessions; - CREATE INDEX idx_uploadsessions_user_id ON UploadSessions(UserId); - END IF; -END// -DELIMITER ; -CALL AlterIndex(); -DROP PROCEDURE IF EXISTS AlterIndex; - -/* ==> mysql/000076_upgrade_lastrootpostat.up.sql <== */ -DELIMITER // -CREATE PROCEDURE Migrate_LastRootPostAt_Default () -BEGIN - IF ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_NAME = 'Channels' - AND TABLE_SCHEMA = DATABASE() - AND COLUMN_NAME = 'LastRootPostAt' - AND (COLUMN_DEFAULT IS NULL OR COLUMN_DEFAULT != 0) - ) = 1 THEN - ALTER TABLE Channels ALTER COLUMN LastRootPostAt SET DEFAULT 0; - END IF; -END// -DELIMITER ; -CALL Migrate_LastRootPostAt_Default (); -DROP PROCEDURE IF EXISTS Migrate_LastRootPostAt_Default; - -DELIMITER // -CREATE PROCEDURE Migrate_LastRootPostAt_Fix () -BEGIN - IF ( - SELECT COUNT(*) - FROM Channels - WHERE LastRootPostAt IS NULL - ) > 0 THEN - -- fixes migrate cte and sets the LastRootPostAt for channels that don't have it set - UPDATE - Channels - INNER JOIN ( - SELECT - Channels.Id channelid, - COALESCE(MAX(Posts.CreateAt), 0) AS lastrootpost - FROM - Channels - LEFT JOIN Posts FORCE INDEX (idx_posts_channel_id_update_at) ON Channels.Id = Posts.ChannelId - WHERE - Posts.RootId = '' - GROUP BY - Channels.Id) AS q ON q.channelid = Channels.Id - SET - LastRootPostAt = lastrootpost - WHERE - LastRootPostAt IS NULL; - - -- sets LastRootPostAt to 0, for channels with no posts - UPDATE Channels SET LastRootPostAt=0 WHERE LastRootPostAt IS NULL; - END IF; -END// -DELIMITER ; -CALL Migrate_LastRootPostAt_Fix (); -DROP PROCEDURE IF EXISTS Migrate_LastRootPostAt_Fix; - -/* ==> mysql/000077_upgrade_users_v6.5.up.sql <== */ - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Users' - AND table_schema = DATABASE() - AND column_name = 'AcceptedServiceTermsId' - ) > 0, - 'ALTER TABLE Users DROP COLUMN AcceptedServiceTermsId;', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -/* ==> mysql/000078_create_oauth_mattermost_app_id.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'OAuthApps' - AND table_schema = DATABASE() - AND column_name = 'MattermostAppID' - ) > 0, - 'SELECT 1', - 'ALTER TABLE OAuthApps ADD COLUMN MattermostAppID varchar(32);' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -/* ==> mysql/000079_usergroups_displayname_index.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'UserGroups' - AND table_schema = DATABASE() - AND index_name = 'idx_usergroups_displayname' - ) > 0, - 'SELECT 1', - 'CREATE INDEX idx_usergroups_displayname ON UserGroups(DisplayName);' -)); - -PREPARE createIndexIfNotExists FROM @preparedStatement; -EXECUTE createIndexIfNotExists; -DEALLOCATE PREPARE createIndexIfNotExists; - -/* ==> mysql/000080_posts_createat_id.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Posts' - AND table_schema = DATABASE() - AND index_name = 'idx_posts_create_at_id' - ) > 0, - 'SELECT 1;', - 'CREATE INDEX idx_posts_create_at_id on Posts(CreateAt, Id) LOCK=NONE;' -)); - -PREPARE createIndexIfNotExists FROM @preparedStatement; -EXECUTE createIndexIfNotExists; -DEALLOCATE PREPARE createIndexIfNotExists; - -/* ==> mysql/000081_threads_deleteat.up.sql <== */ --- Replaced by 000083_threads_threaddeleteat.up.sql - -/* ==> mysql/000082_upgrade_oauth_mattermost_app_id.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'OAuthApps' - AND table_schema = DATABASE() - AND column_name = 'MattermostAppID' - ) > 0, - 'UPDATE OAuthApps SET MattermostAppID = "" WHERE MattermostAppID IS NULL;', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'OAuthApps' - AND table_schema = DATABASE() - AND column_name = 'MattermostAppID' - ) > 0, - 'ALTER TABLE OAuthApps MODIFY MattermostAppID varchar(32) NOT NULL DEFAULT "";', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -/* ==> mysql/000083_threads_threaddeleteat.up.sql <== */ --- Drop any existing DeleteAt column from 000081_threads_deleteat.up.sql -SET @preparedStatement = (SELECT IF( - EXISTS( - SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Threads' - AND table_schema = DATABASE() - AND column_name = 'DeleteAt' - ) > 0, - 'ALTER TABLE Threads DROP COLUMN DeleteAt;', - 'SELECT 1;' -)); - -PREPARE removeColumnIfExists FROM @preparedStatement; -EXECUTE removeColumnIfExists; -DEALLOCATE PREPARE removeColumnIfExists; - -SET @preparedStatement = (SELECT IF( - NOT EXISTS( - SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Threads' - AND table_schema = DATABASE() - AND column_name = 'ThreadDeleteAt' - ), - 'ALTER TABLE Threads ADD COLUMN ThreadDeleteAt bigint(20);', - 'SELECT 1;' -)); - -PREPARE addColumnIfNotExists FROM @preparedStatement; -EXECUTE addColumnIfNotExists; -DEALLOCATE PREPARE addColumnIfNotExists; - -UPDATE Threads, Posts -SET Threads.ThreadDeleteAt = Posts.DeleteAt -WHERE Posts.Id = Threads.PostId -AND Threads.ThreadDeleteAt IS NULL; - -/* ==> mysql/000084_recent_searches.up.sql <== */ -CREATE TABLE IF NOT EXISTS RecentSearches ( - UserId CHAR(26), - SearchPointer int, - Query json, - CreateAt bigint NOT NULL, - PRIMARY KEY (UserId, SearchPointer) -); -/* ==> mysql/000085_fileinfo_add_archived_column.up.sql <== */ - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'FileInfo' - AND table_schema = DATABASE() - AND column_name = 'Archived' - ) > 0, - 'SELECT 1', - 'ALTER TABLE FileInfo ADD COLUMN Archived boolean NOT NULL DEFAULT false;' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -/* ==> mysql/000086_add_cloud_limits_archived.up.sql <== */ -SET @preparedStatement = (SELECT IF( - NOT EXISTS( - SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Teams' - AND table_schema = DATABASE() - AND column_name = 'CloudLimitsArchived' - ), - 'ALTER TABLE Teams ADD COLUMN CloudLimitsArchived BOOLEAN NOT NULL DEFAULT FALSE;', - 'SELECT 1' -)); - -PREPARE alterIfNotExists FROM @preparedStatement; -EXECUTE alterIfNotExists; -DEALLOCATE PREPARE alterIfNotExists; - -/* ==> mysql/000087_sidebar_categories_index.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'SidebarCategories' - AND table_schema = DATABASE() - AND index_name = 'idx_sidebarcategories_userid_teamid' - ) > 0, - 'SELECT 1;', - 'CREATE INDEX idx_sidebarcategories_userid_teamid on SidebarCategories(UserId, TeamId) LOCK=NONE;' -)); - -PREPARE createIndexIfNotExists FROM @preparedStatement; -EXECUTE createIndexIfNotExists; -DEALLOCATE PREPARE createIndexIfNotExists; - -/* ==> mysql/000088_remaining_migrations.up.sql <== */ -DROP TABLE IF EXISTS JobStatuses; - -DROP TABLE IF EXISTS PasswordRecovery; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Users' - AND table_schema = DATABASE() - AND column_name = 'ThemeProps' - ) > 0, - 'INSERT INTO Preferences(UserId, Category, Name, Value) SELECT Id, \'\', \'\', ThemeProps FROM Users WHERE Users.ThemeProps != \'null\'', - 'SELECT 1' -)); - -PREPARE migrateTheme FROM @preparedStatement; -EXECUTE migrateTheme; -DEALLOCATE PREPARE migrateTheme; - --- We have to do this twice because the prepared statement doesn't support multiple SQL queries --- in a single string. - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Users' - AND table_schema = DATABASE() - AND column_name = 'ThemeProps' - ) > 0, - 'ALTER TABLE Users DROP COLUMN ThemeProps', - 'SELECT 1' -)); - -PREPARE migrateTheme FROM @preparedStatement; -EXECUTE migrateTheme; -DEALLOCATE PREPARE migrateTheme; - -/* ==> mysql/000089_add-channelid-to-reaction.up.sql <== */ -SET @preparedStatement = (SELECT IF( - NOT EXISTS( - SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Reactions' - AND table_schema = DATABASE() - AND column_name = 'ChannelId' - ), - 'ALTER TABLE Reactions ADD COLUMN ChannelId varchar(26) NOT NULL DEFAULT "";', - 'SELECT 1;' -)); - -PREPARE addColumnIfNotExists FROM @preparedStatement; -EXECUTE addColumnIfNotExists; -DEALLOCATE PREPARE addColumnIfNotExists; - - -UPDATE Reactions SET ChannelId = COALESCE((select ChannelId from Posts where Posts.Id = Reactions.PostId), '') WHERE ChannelId=""; - - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Reactions' - AND table_schema = DATABASE() - AND index_name = 'idx_reactions_channel_id' - ) > 0, - 'SELECT 1', - 'CREATE INDEX idx_reactions_channel_id ON Reactions(ChannelId);' -)); - -PREPARE createIndexIfNotExists FROM @preparedStatement; -EXECUTE createIndexIfNotExists; -DEALLOCATE PREPARE createIndexIfNotExists; - -/* ==> mysql/000090_create_enums.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Channels' - AND table_schema = DATABASE() - AND column_name = 'Type' - AND column_type != 'ENUM("D", "O", "G", "P")' - ) > 0, - 'ALTER TABLE Channels MODIFY COLUMN Type ENUM("D", "O", "G", "P");', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Teams' - AND table_schema = DATABASE() - AND column_name = 'Type' - AND column_type != 'ENUM("I", "O")' - ) > 0, - 'ALTER TABLE Teams MODIFY COLUMN Type ENUM("I", "O");', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'UploadSessions' - AND table_schema = DATABASE() - AND column_name = 'Type' - AND column_type != 'ENUM("attachment", "import")' - ) > 0, - 'ALTER TABLE UploadSessions MODIFY COLUMN Type ENUM("attachment", "import");', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; -/* ==> mysql/000091_create_post_reminder.up.sql <== */ -CREATE TABLE IF NOT EXISTS PostReminders ( - PostId varchar(26) NOT NULL, - UserId varchar(26) NOT NULL, - TargetTime bigint, - PRIMARY KEY (PostId, UserId) -); - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'PostReminders' - AND table_schema = DATABASE() - AND index_name = 'idx_postreminders_targettime' - ) > 0, - 'SELECT 1', - 'CREATE INDEX idx_postreminders_targettime ON PostReminders(TargetTime);' -)); - -PREPARE createIndexIfNotExists FROM @preparedStatement; -EXECUTE createIndexIfNotExists; -DEALLOCATE PREPARE createIndexIfNotExists; -/* ==> mysql/000092_add_createat_to_teammembers.up.sql <== */ -SET @preparedStatement = (SELECT IF( - NOT EXISTS( - SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'TeamMembers' - AND table_schema = DATABASE() - AND column_name = 'CreateAt' - ), - 'ALTER TABLE TeamMembers ADD COLUMN CreateAt bigint DEFAULT 0;', - 'SELECT 1;' -)); - -PREPARE addColumnIfNotExists FROM @preparedStatement; -EXECUTE addColumnIfNotExists; -DEALLOCATE PREPARE addColumnIfNotExists; - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'TeamMembers' - AND table_schema = DATABASE() - AND index_name = 'idx_teammembers_createat' - ) > 0, - 'SELECT 1', - 'CREATE INDEX idx_teammembers_createat ON TeamMembers(CreateAt);' -)); - -PREPARE createIndexIfNotExists FROM @preparedStatement; -EXECUTE createIndexIfNotExists; -DEALLOCATE PREPARE createIndexIfNotExists; - -/* ==> mysql/000093_notify_admin.up.sql <== */ -CREATE TABLE IF NOT EXISTS NotifyAdmin ( - UserId varchar(26) NOT NULL, - CreateAt bigint(20) DEFAULT NULL, - RequiredPlan varchar(26) NOT NULL, - RequiredFeature varchar(100) NOT NULL, - Trial BOOLEAN NOT NULL, - PRIMARY KEY (UserId, RequiredFeature, RequiredPlan) -); - -/* ==> mysql/000094_threads_teamid.up.sql <== */ --- Replaced by 000096_threads_threadteamid.up.sql - -/* ==> mysql/000095_remove_posts_parentid.up.sql <== */ --- While upgrading from 5.x to 6.x with manual queries, there is a chance that this --- migration is skipped. In that case, we need to make sure that the column is dropped. - -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Posts' - AND table_schema = DATABASE() - AND column_name = 'ParentId' - ) > 0, - 'ALTER TABLE Posts DROP COLUMN ParentId;', - 'SELECT 1' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -/* ==> mysql/000096_threads_threadteamid.up.sql <== */ --- Drop any existing TeamId column from 000094_threads_teamid.up.sql -SET @preparedStatement = (SELECT IF( - EXISTS( - SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS - WHERE table_name = 'Threads' - AND table_schema = DATABASE() - AND column_name = 'TeamId' - ) > 0, - 'ALTER TABLE Threads DROP COLUMN TeamId;', - 'SELECT 1;' -)); - -PREPARE removeColumnIfExists FROM @preparedStatement; -EXECUTE removeColumnIfExists; -DEALLOCATE PREPARE removeColumnIfExists; - -SET @preparedStatement = (SELECT IF( - NOT EXISTS( - SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Threads' - AND table_schema = DATABASE() - AND column_name = 'ThreadTeamId' - ), - 'ALTER TABLE Threads ADD COLUMN ThreadTeamId varchar(26) DEFAULT NULL;', - 'SELECT 1;' -)); - -PREPARE addColumnIfNotExists FROM @preparedStatement; -EXECUTE addColumnIfNotExists; -DEALLOCATE PREPARE addColumnIfNotExists; - -UPDATE Threads, Channels -SET Threads.ThreadTeamId = Channels.TeamId -WHERE Channels.Id = Threads.ChannelId -AND Threads.ThreadTeamId IS NULL; - -/* ==> mysql/000097_create_posts_priority.up.sql <== */ -CREATE TABLE IF NOT EXISTS PostsPriority ( - PostId varchar(26) NOT NULL, - ChannelId varchar(26) NOT NULL, - Priority varchar(32) NOT NULL, - RequestedAck tinyint(1), - PersistentNotifications tinyint(1), - PRIMARY KEY (PostId) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -SET @preparedStatement = (SELECT IF( - NOT EXISTS( - SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'ChannelMembers' - AND table_schema = DATABASE() - AND column_name = 'UrgentMentionCount' - ), - 'ALTER TABLE ChannelMembers ADD COLUMN UrgentMentionCount bigint(20);', - 'SELECT 1;' -)); - -PREPARE alterIfNotExists FROM @preparedStatement; -EXECUTE alterIfNotExists; -DEALLOCATE PREPARE alterIfNotExists; - -/* ==> mysql/000098_create_post_acknowledgements.up.sql <== */ -CREATE TABLE IF NOT EXISTS PostAcknowledgements ( - PostId varchar(26) NOT NULL, - UserId varchar(26) NOT NULL, - AcknowledgedAt bigint(20) DEFAULT NULL, - PRIMARY KEY (PostId, UserId) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -/* ==> mysql/000099_create_drafts.up.sql <== */ -CREATE TABLE IF NOT EXISTS Drafts ( - CreateAt bigint(20) DEFAULT NULL, - UpdateAt bigint(20) DEFAULT NULL, - DeleteAt bigint(20) DEFAULT NULL, - UserId varchar(26) NOT NULL, - ChannelId varchar(26) NOT NULL, - RootId varchar(26) DEFAULT '', - Message text, - Props text, - FileIds text, - PRIMARY KEY (UserId, ChannelId, RootId) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - -/* ==> mysql/000100_add_draft_priority_column.up.sql <== */ -SET @preparedStatement = (SELECT IF( - ( - SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS - WHERE table_name = 'Drafts' - AND table_schema = DATABASE() - AND column_name = 'Priority' - ) > 0, - 'SELECT 1', - 'ALTER TABLE Drafts ADD COLUMN Priority text;' -)); - -PREPARE alterIfExists FROM @preparedStatement; -EXECUTE alterIfExists; -DEALLOCATE PREPARE alterIfExists; - -/* ==> mysql/000101_create_true_up_review_history.up.sql <== */ -CREATE TABLE IF NOT EXISTS TrueUpReviewHistory ( - DueDate bigint(20), - Completed boolean, - PRIMARY KEY (DueDate) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/server/scripts/esrupgrades/esr.common.mysql.preprocess.sql b/server/scripts/esrupgrades/esr.common.mysql.preprocess.sql deleted file mode 100644 index 4c06e1ba19a..00000000000 --- a/server/scripts/esrupgrades/esr.common.mysql.preprocess.sql +++ /dev/null @@ -1,23 +0,0 @@ -/* The sessions in the DB dump may have expired before the CI tests run, making - the server remove the rows and generating a spurious diff that we want to avoid. - In order to do so, we mark all sessions' ExpiresAt value to 0, so they never expire. */ -UPDATE Sessions SET ExpiresAt = 0; - -/* The dump may not contain a system-bot user, in which case the server will create - one if it's not shutdown before a job requests it. This situation creates a flaky - tests in which, in rare ocassions, the system-bot is indeed created, generating a - spurious diff. We avoid this by making sure that there is a system-bot user and - corresponding bot */ -DELIMITER // -CREATE PROCEDURE AddSystemBotIfNeeded () -BEGIN - DECLARE CreateSystemBot BOOLEAN; - SELECT COUNT(*) = 0 FROM Users WHERE Username = 'system-bot' INTO CreateSystemBot; - IF CreateSystemBot THEN - /* These values are retrieved from a real system-bot created by a server */ - INSERT INTO `Bots` VALUES ('nc7y5x1i8jgr9btabqo5m3579c','','phxrtijfrtfg7k4bwj9nophqyc',0,1681308600015,1681308600015,0); - INSERT INTO `Users` VALUES ('nc7y5x1i8jgr9btabqo5m3579c',1681308600014,1681308600014,0,'system-bot','',NULL,'','system-bot@localhost',0,'','System','','','system_user',0,'{}','{\"push\": \"mention\", \"email\": \"true\", \"channel\": \"true\", \"desktop\": \"mention\", \"comments\": \"never\", \"first_name\": \"false\", \"push_status\": \"away\", \"mention_keys\": \"\", \"push_threads\": \"all\", \"desktop_sound\": \"true\", \"email_threads\": \"all\", \"desktop_threads\": \"all\"}',1681308600014,0,0,'en','{\"manualTimezone\": \"\", \"automaticTimezone\": \"\", \"useAutomaticTimezone\": \"true\"}',0,'',NULL); - END IF; -END// -DELIMITER ; -CALL AddSystemBotIfNeeded(); diff --git a/server/scripts/mattermost-mysql-5.0.0.sql b/server/scripts/mattermost-mysql-5.0.0.sql deleted file mode 100644 index 9d2c30ba568..00000000000 --- a/server/scripts/mattermost-mysql-5.0.0.sql +++ /dev/null @@ -1,1057 +0,0 @@ --- MySQL dump 10.13 Distrib 5.7.25, for Linux (x86_64) --- --- Host: 127.0.0.1 Database: mattermost_test --- ------------------------------------------------------ --- Server version 5.7.23 - -/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; -/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; -/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; -/*!40101 SET NAMES utf8 */; -/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; -/*!40103 SET TIME_ZONE='+00:00' */; -/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; -/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; -/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; -/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; - --- --- Table structure for table `Audits` --- - -DROP TABLE IF EXISTS `Audits`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Audits` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UserId` varchar(26) DEFAULT NULL, - `Action` text, - `ExtraInfo` text, - `IpAddress` varchar(64) DEFAULT NULL, - `SessionId` varchar(26) DEFAULT NULL, - PRIMARY KEY (`Id`), - KEY `idx_audits_user_id` (`UserId`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `Audits` --- - -LOCK TABLES `Audits` WRITE; -/*!40000 ALTER TABLE `Audits` DISABLE KEYS */; -/*!40000 ALTER TABLE `Audits` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `ChannelMemberHistory` --- - -DROP TABLE IF EXISTS `ChannelMemberHistory`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `ChannelMemberHistory` ( - `ChannelId` varchar(26) NOT NULL, - `UserId` varchar(26) NOT NULL, - `JoinTime` bigint(20) NOT NULL, - `LeaveTime` bigint(20) DEFAULT NULL, - PRIMARY KEY (`ChannelId`,`UserId`,`JoinTime`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `ChannelMemberHistory` --- - -LOCK TABLES `ChannelMemberHistory` WRITE; -/*!40000 ALTER TABLE `ChannelMemberHistory` DISABLE KEYS */; -/*!40000 ALTER TABLE `ChannelMemberHistory` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `ChannelMembers` --- - -DROP TABLE IF EXISTS `ChannelMembers`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `ChannelMembers` ( - `ChannelId` varchar(26) NOT NULL, - `UserId` varchar(26) NOT NULL, - `Roles` varchar(64) DEFAULT NULL, - `LastViewedAt` bigint(20) DEFAULT NULL, - `MsgCount` bigint(20) DEFAULT NULL, - `MentionCount` bigint(20) DEFAULT NULL, - `NotifyProps` text, - `LastUpdateAt` bigint(20) DEFAULT NULL, - `SchemeUser` tinyint(4) DEFAULT NULL, - `SchemeAdmin` tinyint(4) DEFAULT NULL, - PRIMARY KEY (`ChannelId`,`UserId`), - KEY `idx_channelmembers_channel_id` (`ChannelId`), - KEY `idx_channelmembers_user_id` (`UserId`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `ChannelMembers` --- - -LOCK TABLES `ChannelMembers` WRITE; -/*!40000 ALTER TABLE `ChannelMembers` DISABLE KEYS */; -/*!40000 ALTER TABLE `ChannelMembers` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `Channels` --- - -DROP TABLE IF EXISTS `Channels`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Channels` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `TeamId` varchar(26) DEFAULT NULL, - `Type` varchar(1) DEFAULT NULL, - `DisplayName` varchar(64) DEFAULT NULL, - `Name` varchar(64) DEFAULT NULL, - `Header` text, - `Purpose` varchar(250) DEFAULT NULL, - `LastPostAt` bigint(20) DEFAULT NULL, - `TotalMsgCount` bigint(20) DEFAULT NULL, - `ExtraUpdateAt` bigint(20) DEFAULT NULL, - `CreatorId` varchar(26) DEFAULT NULL, - `SchemeId` varchar(26) DEFAULT NULL, - PRIMARY KEY (`Id`), - UNIQUE KEY `Name` (`Name`,`TeamId`), - KEY `idx_channels_team_id` (`TeamId`), - KEY `idx_channels_name` (`Name`), - KEY `idx_channels_update_at` (`UpdateAt`), - KEY `idx_channels_create_at` (`CreateAt`), - KEY `idx_channels_delete_at` (`DeleteAt`), - FULLTEXT KEY `idx_channels_txt` (`Name`,`DisplayName`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `Channels` --- - -LOCK TABLES `Channels` WRITE; -/*!40000 ALTER TABLE `Channels` DISABLE KEYS */; -/*!40000 ALTER TABLE `Channels` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `ClusterDiscovery` --- - -DROP TABLE IF EXISTS `ClusterDiscovery`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `ClusterDiscovery` ( - `Id` varchar(26) NOT NULL, - `Type` varchar(64) DEFAULT NULL, - `ClusterName` varchar(64) DEFAULT NULL, - `Hostname` text, - `GossipPort` int(11) DEFAULT NULL, - `Port` int(11) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `LastPingAt` bigint(20) DEFAULT NULL, - PRIMARY KEY (`Id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `ClusterDiscovery` --- - -LOCK TABLES `ClusterDiscovery` WRITE; -/*!40000 ALTER TABLE `ClusterDiscovery` DISABLE KEYS */; -/*!40000 ALTER TABLE `ClusterDiscovery` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `CommandWebhooks` --- - -DROP TABLE IF EXISTS `CommandWebhooks`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `CommandWebhooks` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `CommandId` varchar(26) DEFAULT NULL, - `UserId` varchar(26) DEFAULT NULL, - `ChannelId` varchar(26) DEFAULT NULL, - `RootId` varchar(26) DEFAULT NULL, - `ParentId` varchar(26) DEFAULT NULL, - `UseCount` int(11) DEFAULT NULL, - PRIMARY KEY (`Id`), - KEY `idx_command_webhook_create_at` (`CreateAt`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `CommandWebhooks` --- - -LOCK TABLES `CommandWebhooks` WRITE; -/*!40000 ALTER TABLE `CommandWebhooks` DISABLE KEYS */; -/*!40000 ALTER TABLE `CommandWebhooks` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `Commands` --- - -DROP TABLE IF EXISTS `Commands`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Commands` ( - `Id` varchar(26) NOT NULL, - `Token` varchar(26) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `CreatorId` varchar(26) DEFAULT NULL, - `TeamId` varchar(26) DEFAULT NULL, - `Trigger` varchar(128) DEFAULT NULL, - `Method` varchar(1) DEFAULT NULL, - `Username` varchar(64) DEFAULT NULL, - `IconURL` text, - `AutoComplete` tinyint(1) DEFAULT NULL, - `AutoCompleteDesc` text, - `AutoCompleteHint` text, - `DisplayName` varchar(64) DEFAULT NULL, - `Description` varchar(128) DEFAULT NULL, - `URL` text, - PRIMARY KEY (`Id`), - KEY `idx_command_team_id` (`TeamId`), - KEY `idx_command_update_at` (`UpdateAt`), - KEY `idx_command_create_at` (`CreateAt`), - KEY `idx_command_delete_at` (`DeleteAt`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `Commands` --- - -LOCK TABLES `Commands` WRITE; -/*!40000 ALTER TABLE `Commands` DISABLE KEYS */; -/*!40000 ALTER TABLE `Commands` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `Compliances` --- - -DROP TABLE IF EXISTS `Compliances`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Compliances` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UserId` varchar(26) DEFAULT NULL, - `Status` varchar(64) DEFAULT NULL, - `Count` int(11) DEFAULT NULL, - `Desc` text, - `Type` varchar(64) DEFAULT NULL, - `StartAt` bigint(20) DEFAULT NULL, - `EndAt` bigint(20) DEFAULT NULL, - `Keywords` text, - `Emails` text, - PRIMARY KEY (`Id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `Compliances` --- - -LOCK TABLES `Compliances` WRITE; -/*!40000 ALTER TABLE `Compliances` DISABLE KEYS */; -/*!40000 ALTER TABLE `Compliances` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `Emoji` --- - -DROP TABLE IF EXISTS `Emoji`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Emoji` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `CreatorId` varchar(26) DEFAULT NULL, - `Name` varchar(64) DEFAULT NULL, - PRIMARY KEY (`Id`), - UNIQUE KEY `Name` (`Name`,`DeleteAt`), - KEY `idx_emoji_update_at` (`UpdateAt`), - KEY `idx_emoji_create_at` (`CreateAt`), - KEY `idx_emoji_delete_at` (`DeleteAt`), - KEY `idx_emoji_name` (`Name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `Emoji` --- - -LOCK TABLES `Emoji` WRITE; -/*!40000 ALTER TABLE `Emoji` DISABLE KEYS */; -/*!40000 ALTER TABLE `Emoji` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `FileInfo` --- - -DROP TABLE IF EXISTS `FileInfo`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `FileInfo` ( - `Id` varchar(26) NOT NULL, - `CreatorId` varchar(26) DEFAULT NULL, - `PostId` varchar(26) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `Path` text, - `ThumbnailPath` text, - `PreviewPath` text, - `Name` text, - `Extension` varchar(64) DEFAULT NULL, - `Size` bigint(20) DEFAULT NULL, - `MimeType` text, - `Width` int(11) DEFAULT NULL, - `Height` int(11) DEFAULT NULL, - `HasPreviewImage` tinyint(1) DEFAULT NULL, - PRIMARY KEY (`Id`), - KEY `idx_fileinfo_update_at` (`UpdateAt`), - KEY `idx_fileinfo_create_at` (`CreateAt`), - KEY `idx_fileinfo_delete_at` (`DeleteAt`), - KEY `idx_fileinfo_postid_at` (`PostId`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `FileInfo` --- - -LOCK TABLES `FileInfo` WRITE; -/*!40000 ALTER TABLE `FileInfo` DISABLE KEYS */; -/*!40000 ALTER TABLE `FileInfo` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `IncomingWebhooks` --- - -DROP TABLE IF EXISTS `IncomingWebhooks`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `IncomingWebhooks` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `UserId` varchar(26) DEFAULT NULL, - `ChannelId` varchar(26) DEFAULT NULL, - `TeamId` varchar(26) DEFAULT NULL, - `DisplayName` varchar(64) DEFAULT NULL, - `Description` text, - `Username` varchar(255) DEFAULT NULL, - `IconURL` text, - `ChannelLocked` tinyint(1) DEFAULT 0, - PRIMARY KEY (`Id`), - KEY `idx_incoming_webhook_user_id` (`UserId`), - KEY `idx_incoming_webhook_team_id` (`TeamId`), - KEY `idx_incoming_webhook_update_at` (`UpdateAt`), - KEY `idx_incoming_webhook_create_at` (`CreateAt`), - KEY `idx_incoming_webhook_delete_at` (`DeleteAt`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `IncomingWebhooks` --- - -LOCK TABLES `IncomingWebhooks` WRITE; -/*!40000 ALTER TABLE `IncomingWebhooks` DISABLE KEYS */; -/*!40000 ALTER TABLE `IncomingWebhooks` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `Jobs` --- - -DROP TABLE IF EXISTS `Jobs`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Jobs` ( - `Id` varchar(26) NOT NULL, - `Type` varchar(32) DEFAULT NULL, - `Priority` bigint(20) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `StartAt` bigint(20) DEFAULT NULL, - `LastActivityAt` bigint(20) DEFAULT NULL, - `Status` varchar(32) DEFAULT NULL, - `Progress` bigint(20) DEFAULT NULL, - `Data` text, - PRIMARY KEY (`Id`), - KEY `idx_jobs_type` (`Type`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `Jobs` --- - -LOCK TABLES `Jobs` WRITE; -/*!40000 ALTER TABLE `Jobs` DISABLE KEYS */; -/*!40000 ALTER TABLE `Jobs` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `Licenses` --- - -DROP TABLE IF EXISTS `Licenses`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Licenses` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `Bytes` text, - PRIMARY KEY (`Id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `Licenses` --- - -LOCK TABLES `Licenses` WRITE; -/*!40000 ALTER TABLE `Licenses` DISABLE KEYS */; -/*!40000 ALTER TABLE `Licenses` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `OAuthAccessData` --- - -DROP TABLE IF EXISTS `OAuthAccessData`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `OAuthAccessData` ( - `Token` varchar(26) NOT NULL, - `RefreshToken` varchar(26) DEFAULT NULL, - `RedirectUri` text, - `ClientId` varchar(26) DEFAULT NULL, - `UserId` varchar(26) DEFAULT NULL, - `ExpiresAt` bigint(20) DEFAULT '0', - `Scope` varchar(128) DEFAULT 'user', - PRIMARY KEY (`Token`), - UNIQUE KEY `ClientId` (`ClientId`,`UserId`), - KEY `idx_oauthaccessdata_client_id` (`ClientId`), - KEY `idx_oauthaccessdata_user_id` (`UserId`), - KEY `idx_oauthaccessdata_refresh_token` (`RefreshToken`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `OAuthAccessData` --- - -LOCK TABLES `OAuthAccessData` WRITE; -/*!40000 ALTER TABLE `OAuthAccessData` DISABLE KEYS */; -/*!40000 ALTER TABLE `OAuthAccessData` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `OAuthApps` --- - -DROP TABLE IF EXISTS `OAuthApps`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `OAuthApps` ( - `Id` varchar(26) NOT NULL, - `CreatorId` varchar(26) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `ClientSecret` varchar(128) DEFAULT NULL, - `Name` varchar(64) DEFAULT NULL, - `Description` text, - `CallbackUrls` text, - `Homepage` text, - `IsTrusted` tinyint(1) DEFAULT '0', - `IconURL` varchar(512) DEFAULT '', - PRIMARY KEY (`Id`), - KEY `idx_oauthapps_creator_id` (`CreatorId`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `OAuthApps` --- - -LOCK TABLES `OAuthApps` WRITE; -/*!40000 ALTER TABLE `OAuthApps` DISABLE KEYS */; -/*!40000 ALTER TABLE `OAuthApps` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `OAuthAuthData` --- - -DROP TABLE IF EXISTS `OAuthAuthData`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `OAuthAuthData` ( - `ClientId` varchar(26) DEFAULT NULL, - `UserId` varchar(26) DEFAULT NULL, - `Code` varchar(128) NOT NULL, - `ExpiresIn` int(11) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `RedirectUri` text, - `State` text, - `Scope` varchar(128) DEFAULT NULL, - PRIMARY KEY (`Code`), - KEY `idx_oauthauthdata_client_id` (`Code`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `OAuthAuthData` --- - -LOCK TABLES `OAuthAuthData` WRITE; -/*!40000 ALTER TABLE `OAuthAuthData` DISABLE KEYS */; -/*!40000 ALTER TABLE `OAuthAuthData` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `OutgoingWebhooks` --- - -DROP TABLE IF EXISTS `OutgoingWebhooks`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `OutgoingWebhooks` ( - `Id` varchar(26) NOT NULL, - `Token` varchar(26) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `CreatorId` varchar(26) DEFAULT NULL, - `ChannelId` varchar(26) DEFAULT NULL, - `TeamId` varchar(26) DEFAULT NULL, - `TriggerWords` text, - `CallbackURLs` text, - `DisplayName` varchar(64) DEFAULT NULL, - `ContentType` varchar(128) DEFAULT NULL, - `TriggerWhen` int(11) DEFAULT '0', - `Username` varchar(64) DEFAULT NULL, - `IconURL` text, - `Description` text(128), - PRIMARY KEY (`Id`), - KEY `idx_outgoing_webhook_team_id` (`TeamId`), - KEY `idx_outgoing_webhook_update_at` (`UpdateAt`), - KEY `idx_outgoing_webhook_create_at` (`CreateAt`), - KEY `idx_outgoing_webhook_delete_at` (`DeleteAt`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `OutgoingWebhooks` --- - -LOCK TABLES `OutgoingWebhooks` WRITE; -/*!40000 ALTER TABLE `OutgoingWebhooks` DISABLE KEYS */; -/*!40000 ALTER TABLE `OutgoingWebhooks` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `PluginKeyValueStore` --- - -DROP TABLE IF EXISTS `PluginKeyValueStore`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `PluginKeyValueStore` ( - `PluginId` varchar(190) NOT NULL, - `PKey` varchar(50) NOT NULL, - `PValue` mediumblob, - PRIMARY KEY (`PluginId`,`PKey`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `PluginKeyValueStore` --- - -LOCK TABLES `PluginKeyValueStore` WRITE; -/*!40000 ALTER TABLE `PluginKeyValueStore` DISABLE KEYS */; -/*!40000 ALTER TABLE `PluginKeyValueStore` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `Posts` --- - -DROP TABLE IF EXISTS `Posts`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Posts` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `UserId` varchar(26) DEFAULT NULL, - `ChannelId` varchar(26) DEFAULT NULL, - `RootId` varchar(26) DEFAULT NULL, - `ParentId` varchar(26) DEFAULT NULL, - `OriginalId` varchar(26) DEFAULT NULL, - `Message` text, - `Type` varchar(26) DEFAULT NULL, - `Props` text, - `Hashtags` text, - `Filenames` text, - `FileIds` text, - `HasReactions` tinyint(1) DEFAULT '0', - `EditAt` bigint(20) DEFAULT '0', - `IsPinned` tinyint(1) DEFAULT '0', - PRIMARY KEY (`Id`), - KEY `idx_posts_update_at` (`UpdateAt`), - KEY `idx_posts_create_at` (`CreateAt`), - KEY `idx_posts_delete_at` (`DeleteAt`), - KEY `idx_posts_channel_id` (`ChannelId`), - KEY `idx_posts_root_id` (`RootId`), - KEY `idx_posts_user_id` (`UserId`), - KEY `idx_posts_is_pinned` (`IsPinned`), - KEY `idx_posts_channel_id_update_at` (`ChannelId`,`UpdateAt`), - KEY `idx_posts_channel_id_delete_at_create_at` (`ChannelId`,`DeleteAt`,`CreateAt`), - FULLTEXT KEY `idx_posts_message_txt` (`Message`), - FULLTEXT KEY `idx_posts_hashtags_txt` (`Hashtags`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `Posts` --- - -LOCK TABLES `Posts` WRITE; -/*!40000 ALTER TABLE `Posts` DISABLE KEYS */; -/*!40000 ALTER TABLE `Posts` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `Preferences` --- - -DROP TABLE IF EXISTS `Preferences`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Preferences` ( - `UserId` varchar(26) NOT NULL, - `Category` varchar(32) NOT NULL, - `Name` varchar(32) NOT NULL, - `Value` text, - PRIMARY KEY (`UserId`,`Category`,`Name`), - KEY `idx_preferences_user_id` (`UserId`), - KEY `idx_preferences_category` (`Category`), - KEY `idx_preferences_name` (`Name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `Preferences` --- - -LOCK TABLES `Preferences` WRITE; -/*!40000 ALTER TABLE `Preferences` DISABLE KEYS */; -/*!40000 ALTER TABLE `Preferences` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `Reactions` --- - -DROP TABLE IF EXISTS `Reactions`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Reactions` ( - `UserId` varchar(26) NOT NULL, - `PostId` varchar(26) NOT NULL, - `EmojiName` varchar(64) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - PRIMARY KEY (`PostId`,`UserId`,`EmojiName`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `Reactions` --- - -LOCK TABLES `Reactions` WRITE; -/*!40000 ALTER TABLE `Reactions` DISABLE KEYS */; -/*!40000 ALTER TABLE `Reactions` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `Roles` --- - -DROP TABLE IF EXISTS `Roles`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Roles` ( - `Id` varchar(26) NOT NULL, - `Name` varchar(64) DEFAULT NULL, - `DisplayName` varchar(128) DEFAULT NULL, - `Description` text, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `Permissions` text, - `SchemeManaged` tinyint(1) DEFAULT NULL, - `BuiltIn` tinyint(1) DEFAULT '0', - PRIMARY KEY (`Id`), - UNIQUE KEY `Name` (`Name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `Roles` --- - -LOCK TABLES `Roles` WRITE; -/*!40000 ALTER TABLE `Roles` DISABLE KEYS */; -INSERT INTO `Roles` VALUES ('1x1ypn6zwbrubc3i7urg1qc4hr','team_user','authentication.roles.team_user.name','authentication.roles.team_user.description',1552023386683,1552023386683,0,' list_team_channels join_public_channels read_public_channel view_team create_public_channel manage_public_channel_properties delete_public_channel create_private_channel manage_private_channel_properties delete_private_channel invite_user add_user_to_team',1,1),('9ro6s3aiffbomdsm1dszr1gxec','team_post_all','authentication.roles.team_post_all.name','authentication.roles.team_post_all.description',1552023386717,1552023386717,0,' create_post',0,1),('api7kwbqwjbrtp8b5zq1d5ot8w','system_user_access_token','authentication.roles.system_user_access_token.name','authentication.roles.system_user_access_token.description',1552023386784,1552023386784,0,' create_user_access_token read_user_access_token revoke_user_access_token',0,1),('b5hwuid8ofdb9eoca1skzepmoy','team_post_all_public','authentication.roles.team_post_all_public.name','authentication.roles.team_post_all_public.description',1552023386184,1552023386184,0,' create_post_public',0,1),('j79gy46igfrztkyihuqm38h51y','system_user','authentication.roles.global_user.name','authentication.roles.global_user.description',1552023386370,1552023386918,0,' create_direct_channel create_group_channel permanent_delete_user create_team manage_emojis',1,1),('miqk4yzctbyoxg8ye3sbfuoa9y','channel_user','authentication.roles.channel_user.name','authentication.roles.channel_user.description',1552023386587,1552023386587,0,' read_channel add_reaction remove_reaction manage_public_channel_members upload_file get_public_link create_post use_slash_commands manage_private_channel_members delete_post edit_post',1,1),('myf6w6mm5pbabx1dfhxbc9wyyy','system_post_all','authentication.roles.system_post_all.name','authentication.roles.system_post_all.description',1552023386460,1552023386460,0,' create_post',0,1),('nzwf773izfrkirwy47ow3o1xca','system_post_all_public','authentication.roles.system_post_all_public.name','authentication.roles.system_post_all_public.description',1552023386751,1552023386751,0,' create_post_public',0,1),('rhsqatx4yjnk8cwjh785p9tabo','system_admin','authentication.roles.global_admin.name','authentication.roles.global_admin.description',1552023386505,1552023386953,0,' assign_system_admin_role manage_system manage_roles manage_public_channel_properties manage_public_channel_members manage_private_channel_members delete_public_channel create_public_channel manage_private_channel_properties delete_private_channel create_private_channel manage_system_wide_oauth manage_others_webhooks edit_other_users manage_oauth invite_user delete_post delete_others_posts create_team add_user_to_team list_users_without_team manage_jobs create_post_public create_post_ephemeral create_user_access_token read_user_access_token revoke_user_access_token remove_others_reactions list_team_channels join_public_channels read_public_channel view_team read_channel add_reaction remove_reaction upload_file get_public_link create_post use_slash_commands edit_others_posts remove_user_from_team manage_team import_team manage_team_roles manage_channel_roles manage_slash_commands manage_others_slash_commands manage_webhooks edit_post manage_emojis manage_others_emojis',1,1),('s3uda9wt7p8cinzyyjb418o99h','team_admin','authentication.roles.team_admin.name','authentication.roles.team_admin.description',1552023386281,1552023386281,0,' edit_others_posts remove_user_from_team manage_team import_team manage_team_roles manage_channel_roles manage_others_webhooks manage_slash_commands manage_others_slash_commands manage_webhooks delete_post delete_others_posts',1,1),('uowhz7j9s3gx7r37b1twk87uhy','channel_admin','authentication.roles.channel_admin.name','authentication.roles.channel_admin.description',1552023386649,1552023386649,0,' manage_channel_roles',1,1); -/*!40000 ALTER TABLE `Roles` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `Schemes` --- - -DROP TABLE IF EXISTS `Schemes`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Schemes` ( - `Id` varchar(26) NOT NULL, - `Name` varchar(64) DEFAULT NULL, - `DisplayName` varchar(128) DEFAULT NULL, - `Description` text, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `Scope` varchar(32) DEFAULT NULL, - `DefaultTeamAdminRole` varchar(64) DEFAULT NULL, - `DefaultTeamUserRole` varchar(64) DEFAULT NULL, - `DefaultChannelAdminRole` varchar(64) DEFAULT NULL, - `DefaultChannelUserRole` varchar(64) DEFAULT NULL, - PRIMARY KEY (`Id`), - UNIQUE KEY `Name` (`Name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `Schemes` --- - -LOCK TABLES `Schemes` WRITE; -/*!40000 ALTER TABLE `Schemes` DISABLE KEYS */; -/*!40000 ALTER TABLE `Schemes` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `Sessions` --- - -DROP TABLE IF EXISTS `Sessions`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Sessions` ( - `Id` varchar(26) NOT NULL, - `Token` varchar(26) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `ExpiresAt` bigint(20) DEFAULT NULL, - `LastActivityAt` bigint(20) DEFAULT NULL, - `UserId` varchar(26) DEFAULT NULL, - `DeviceId` text, - `Roles` varchar(64) DEFAULT NULL, - `IsOAuth` tinyint(1) DEFAULT NULL, - `Props` text, - `ExpiredNotify` tinyint(1) DEFAULT '0', - PRIMARY KEY (`Id`), - KEY `idx_sessions_user_id` (`UserId`), - KEY `idx_sessions_token` (`Token`), - KEY `idx_sessions_expires_at` (`ExpiresAt`), - KEY `idx_sessions_create_at` (`CreateAt`), - KEY `idx_sessions_last_activity_at` (`LastActivityAt`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `Sessions` --- - -LOCK TABLES `Sessions` WRITE; -/*!40000 ALTER TABLE `Sessions` DISABLE KEYS */; -/*!40000 ALTER TABLE `Sessions` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `Status` --- - -DROP TABLE IF EXISTS `Status`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Status` ( - `UserId` varchar(26) NOT NULL, - `Status` varchar(32) DEFAULT NULL, - `LastActivityAt` bigint(20) DEFAULT NULL, - `Manual` tinyint(1) DEFAULT '0', - PRIMARY KEY (`UserId`), - KEY `idx_status_user_id` (`UserId`), - KEY `idx_status_status` (`Status`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `Status` --- - -LOCK TABLES `Status` WRITE; -/*!40000 ALTER TABLE `Status` DISABLE KEYS */; -/*!40000 ALTER TABLE `Status` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `Systems` --- - -DROP TABLE IF EXISTS `Systems`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Systems` ( - `Name` varchar(64) NOT NULL, - `Value` text, - PRIMARY KEY (`Name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `Systems` --- - -LOCK TABLES `Systems` WRITE; -/*!40000 ALTER TABLE `Systems` DISABLE KEYS */; -INSERT INTO `Systems` VALUES ('AdvancedPermissionsMigrationComplete','true'),('AsymmetricSigningKey','{\"ecdsa_key\":{\"curve\":\"P-256\",\"x\":85473606765277885426098572272657839969684858397331487822403961213130481697183,\"y\":21768024169009006215752583806332445525165014299802801588411746356748078619048,\"d\":77856411969234342853943455407675564464050187128050756722674285242344366590495}}'),('DiagnosticId','8a6b57ugyigti8aqbmzjqixgoe'),('EmojisPermissionsMigrationComplete','true'),('LastSecurityTime','1552023388297'),('Version','5.0.0'); -/*!40000 ALTER TABLE `Systems` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `TeamMembers` --- - -DROP TABLE IF EXISTS `TeamMembers`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `TeamMembers` ( - `TeamId` varchar(26) NOT NULL, - `UserId` varchar(26) NOT NULL, - `Roles` varchar(64) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `SchemeUser` tinyint(4) DEFAULT NULL, - `SchemeAdmin` tinyint(4) DEFAULT NULL, - PRIMARY KEY (`TeamId`,`UserId`), - KEY `idx_teammembers_team_id` (`TeamId`), - KEY `idx_teammembers_user_id` (`UserId`), - KEY `idx_teammembers_delete_at` (`DeleteAt`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `TeamMembers` --- - -LOCK TABLES `TeamMembers` WRITE; -/*!40000 ALTER TABLE `TeamMembers` DISABLE KEYS */; -/*!40000 ALTER TABLE `TeamMembers` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `Teams` --- - -DROP TABLE IF EXISTS `Teams`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Teams` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `DisplayName` varchar(64) DEFAULT NULL, - `Name` varchar(64) DEFAULT NULL, - `Description` varchar(255) DEFAULT NULL, - `Email` varchar(128) DEFAULT NULL, - `Type` varchar(255) DEFAULT NULL, - `CompanyName` varchar(64) DEFAULT NULL, - `AllowedDomains` text, - `InviteId` varchar(32) DEFAULT NULL, - `SchemeId` varchar(255) DEFAULT NULL, - `AllowOpenInvite` tinyint(1) DEFAULT NULL, - `LastTeamIconUpdate` bigint(20) DEFAULT NULL, - PRIMARY KEY (`Id`), - UNIQUE KEY `Name` (`Name`), - KEY `idx_teams_name` (`Name`), - KEY `idx_teams_invite_id` (`InviteId`), - KEY `idx_teams_update_at` (`UpdateAt`), - KEY `idx_teams_create_at` (`CreateAt`), - KEY `idx_teams_delete_at` (`DeleteAt`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `Teams` --- - -LOCK TABLES `Teams` WRITE; -/*!40000 ALTER TABLE `Teams` DISABLE KEYS */; -/*!40000 ALTER TABLE `Teams` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `Tokens` --- - -DROP TABLE IF EXISTS `Tokens`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Tokens` ( - `Token` varchar(64) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `Type` varchar(64) DEFAULT NULL, - `Extra` varchar(128) DEFAULT NULL, - PRIMARY KEY (`Token`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `Tokens` --- - -LOCK TABLES `Tokens` WRITE; -/*!40000 ALTER TABLE `Tokens` DISABLE KEYS */; -/*!40000 ALTER TABLE `Tokens` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `UserAccessTokens` --- - -DROP TABLE IF EXISTS `UserAccessTokens`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `UserAccessTokens` ( - `Id` varchar(26) NOT NULL, - `Token` varchar(26) DEFAULT NULL, - `UserId` varchar(26) DEFAULT NULL, - `Description` text, - `IsActive` tinyint(1) DEFAULT '1', - PRIMARY KEY (`Id`), - UNIQUE KEY `Token` (`Token`), - KEY `idx_user_access_tokens_token` (`Token`), - KEY `idx_user_access_tokens_user_id` (`UserId`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `UserAccessTokens` --- - -LOCK TABLES `UserAccessTokens` WRITE; -/*!40000 ALTER TABLE `UserAccessTokens` DISABLE KEYS */; -/*!40000 ALTER TABLE `UserAccessTokens` ENABLE KEYS */; -UNLOCK TABLES; - --- --- Table structure for table `Users` --- - -DROP TABLE IF EXISTS `Users`; -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Users` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `Username` varchar(64) DEFAULT NULL, - `Password` varchar(128) DEFAULT NULL, - `AuthData` varchar(128) DEFAULT NULL, - `AuthService` varchar(32) DEFAULT NULL, - `Email` varchar(128) DEFAULT NULL, - `EmailVerified` tinyint(1) DEFAULT NULL, - `Nickname` varchar(64) DEFAULT NULL, - `FirstName` varchar(64) DEFAULT NULL, - `LastName` varchar(64) DEFAULT NULL, - `Roles` varchar(256) DEFAULT NULL, - `AllowMarketing` tinyint(1) DEFAULT NULL, - `Props` text, - `NotifyProps` text, - `LastPasswordUpdate` bigint(20) DEFAULT NULL, - `LastPictureUpdate` bigint(20) DEFAULT NULL, - `FailedAttempts` int(11) DEFAULT NULL, - `Locale` varchar(5) DEFAULT NULL, - `MfaActive` tinyint(1) DEFAULT NULL, - `MfaSecret` varchar(128) DEFAULT NULL, - `Position` varchar(128) DEFAULT NULL, - `Timezone` varchar(256) DEFAULT '{"automaticTimezone":"","manualTimezone":"","useAutomaticTimezone":"true"}', - PRIMARY KEY (`Id`), - UNIQUE KEY `Username` (`Username`), - UNIQUE KEY `AuthData` (`AuthData`), - UNIQUE KEY `Email` (`Email`), - KEY `idx_users_email` (`Email`), - KEY `idx_users_update_at` (`UpdateAt`), - KEY `idx_users_create_at` (`CreateAt`), - KEY `idx_users_delete_at` (`DeleteAt`), - FULLTEXT KEY `idx_users_all_txt` (`Username`,`FirstName`,`LastName`,`Nickname`,`Email`), - FULLTEXT KEY `idx_users_all_no_full_name_txt` (`Username`,`Nickname`,`Email`), - FULLTEXT KEY `idx_users_names_txt` (`Username`,`FirstName`,`LastName`,`Nickname`), - FULLTEXT KEY `idx_users_names_no_full_name_txt` (`Username`,`Nickname`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Dumping data for table `Users` --- - -LOCK TABLES `Users` WRITE; -/*!40000 ALTER TABLE `Users` DISABLE KEYS */; -/*!40000 ALTER TABLE `Users` ENABLE KEYS */; -UNLOCK TABLES; -/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; - -/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; -/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; -/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; -/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; -/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; -/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; -/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; - --- Dump completed on 2019-03-08 11:06:52 diff --git a/server/scripts/mattermost-mysql-6.0.0.sql b/server/scripts/mattermost-mysql-6.0.0.sql deleted file mode 100644 index 28516913755..00000000000 --- a/server/scripts/mattermost-mysql-6.0.0.sql +++ /dev/null @@ -1,1198 +0,0 @@ --- MariaDB dump 10.19 Distrib 10.5.10-MariaDB, for Linux (x86_64) --- --- Host: 127.0.0.1 Database: mattermost_test --- ------------------------------------------------------ --- Server version 5.7.12 -/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; -/*!40103 SET TIME_ZONE='+00:00' */; -/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; -/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; -/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; -/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; - --- --- Table structure for table `Audits` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Audits` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UserId` varchar(26) DEFAULT NULL, - `Action` text, - `ExtraInfo` text, - `IpAddress` varchar(64) DEFAULT NULL, - `SessionId` varchar(26) DEFAULT NULL, - PRIMARY KEY (`Id`), - KEY `idx_audits_user_id` (`UserId`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `Bots` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Bots` ( - `UserId` varchar(26) NOT NULL, - `Description` text, - `OwnerId` varchar(190) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `LastIconUpdate` bigint(20) DEFAULT NULL, - PRIMARY KEY (`UserId`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `ChannelMemberHistory` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `ChannelMemberHistory` ( - `ChannelId` varchar(26) NOT NULL, - `UserId` varchar(26) NOT NULL, - `JoinTime` bigint(20) NOT NULL, - `LeaveTime` bigint(20) DEFAULT NULL, - PRIMARY KEY (`ChannelId`,`UserId`,`JoinTime`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `ChannelMembers` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `ChannelMembers` ( - `ChannelId` varchar(26) NOT NULL, - `UserId` varchar(26) NOT NULL, - `Roles` varchar(64) DEFAULT NULL, - `LastViewedAt` bigint(20) DEFAULT NULL, - `MsgCount` bigint(20) DEFAULT NULL, - `MentionCount` bigint(20) DEFAULT NULL, - `NotifyProps` json DEFAULT NULL, - `LastUpdateAt` bigint(20) DEFAULT NULL, - `SchemeUser` tinyint(4) DEFAULT NULL, - `SchemeAdmin` tinyint(4) DEFAULT NULL, - `SchemeGuest` tinyint(4) DEFAULT NULL, - `MentionCountRoot` bigint(20) DEFAULT NULL, - `MsgCountRoot` bigint(20) DEFAULT NULL, - PRIMARY KEY (`ChannelId`,`UserId`), - KEY `idx_channelmembers_user_id_channel_id_last_viewed_at` (`UserId`,`ChannelId`,`LastViewedAt`), - KEY `idx_channelmembers_channel_id_scheme_guest_user_id` (`ChannelId`,`SchemeGuest`,`UserId`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `Channels` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Channels` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `TeamId` varchar(26) DEFAULT NULL, - `Type` varchar(1) DEFAULT NULL, - `DisplayName` varchar(64) DEFAULT NULL, - `Name` varchar(64) DEFAULT NULL, - `Header` text, - `Purpose` varchar(250) DEFAULT NULL, - `LastPostAt` bigint(20) DEFAULT NULL, - `TotalMsgCount` bigint(20) DEFAULT NULL, - `ExtraUpdateAt` bigint(20) DEFAULT NULL, - `CreatorId` varchar(26) DEFAULT NULL, - `SchemeId` varchar(26) DEFAULT NULL, - `GroupConstrained` tinyint(1) DEFAULT NULL, - `Shared` tinyint(1) DEFAULT NULL, - `TotalMsgCountRoot` bigint(20) DEFAULT NULL, - PRIMARY KEY (`Id`), - UNIQUE KEY `Name` (`Name`,`TeamId`), - KEY `idx_channels_update_at` (`UpdateAt`), - KEY `idx_channels_create_at` (`CreateAt`), - KEY `idx_channels_delete_at` (`DeleteAt`), - KEY `idx_channels_scheme_id` (`SchemeId`), - KEY `idx_channels_team_id_display_name` (`TeamId`,`DisplayName`), - KEY `idx_channels_team_id_type` (`TeamId`,`Type`), - FULLTEXT KEY `idx_channel_search_txt` (`Name`,`DisplayName`,`Purpose`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `ClusterDiscovery` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `ClusterDiscovery` ( - `Id` varchar(26) NOT NULL, - `Type` varchar(64) DEFAULT NULL, - `ClusterName` varchar(64) DEFAULT NULL, - `Hostname` text, - `GossipPort` int(11) DEFAULT NULL, - `Port` int(11) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `LastPingAt` bigint(20) DEFAULT NULL, - PRIMARY KEY (`Id`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `CommandWebhooks` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `CommandWebhooks` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `CommandId` varchar(26) DEFAULT NULL, - `UserId` varchar(26) DEFAULT NULL, - `ChannelId` varchar(26) DEFAULT NULL, - `RootId` varchar(26) DEFAULT NULL, - `UseCount` int(11) DEFAULT NULL, - PRIMARY KEY (`Id`), - KEY `idx_command_webhook_create_at` (`CreateAt`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `Commands` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Commands` ( - `Id` varchar(26) NOT NULL, - `Token` varchar(26) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `CreatorId` varchar(26) DEFAULT NULL, - `TeamId` varchar(26) DEFAULT NULL, - `Trigger` varchar(128) DEFAULT NULL, - `Method` varchar(1) DEFAULT NULL, - `Username` varchar(64) DEFAULT NULL, - `IconURL` text, - `AutoComplete` tinyint(1) DEFAULT NULL, - `AutoCompleteDesc` text, - `AutoCompleteHint` text, - `DisplayName` varchar(64) DEFAULT NULL, - `Description` varchar(128) DEFAULT NULL, - `URL` text, - `PluginId` varchar(190) DEFAULT NULL, - PRIMARY KEY (`Id`), - KEY `idx_command_team_id` (`TeamId`), - KEY `idx_command_update_at` (`UpdateAt`), - KEY `idx_command_create_at` (`CreateAt`), - KEY `idx_command_delete_at` (`DeleteAt`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `Compliances` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Compliances` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UserId` varchar(26) DEFAULT NULL, - `Status` varchar(64) DEFAULT NULL, - `Count` int(11) DEFAULT NULL, - `Desc` text, - `Type` varchar(64) DEFAULT NULL, - `StartAt` bigint(20) DEFAULT NULL, - `EndAt` bigint(20) DEFAULT NULL, - `Keywords` text, - `Emails` text, - PRIMARY KEY (`Id`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `Emoji` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Emoji` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `CreatorId` varchar(26) DEFAULT NULL, - `Name` varchar(64) DEFAULT NULL, - PRIMARY KEY (`Id`), - UNIQUE KEY `Name` (`Name`,`DeleteAt`), - KEY `idx_emoji_update_at` (`UpdateAt`), - KEY `idx_emoji_create_at` (`CreateAt`), - KEY `idx_emoji_delete_at` (`DeleteAt`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `FileInfo` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `FileInfo` ( - `Id` varchar(26) NOT NULL, - `CreatorId` varchar(26) DEFAULT NULL, - `PostId` varchar(26) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `Path` text, - `ThumbnailPath` text, - `PreviewPath` text, - `Name` text, - `Extension` varchar(64) DEFAULT NULL, - `Size` bigint(20) DEFAULT NULL, - `MimeType` text, - `Width` int(11) DEFAULT NULL, - `Height` int(11) DEFAULT NULL, - `HasPreviewImage` tinyint(1) DEFAULT NULL, - `MiniPreview` mediumblob, - `Content` longtext, - `RemoteId` varchar(26) DEFAULT NULL, - PRIMARY KEY (`Id`), - KEY `idx_fileinfo_update_at` (`UpdateAt`), - KEY `idx_fileinfo_create_at` (`CreateAt`), - KEY `idx_fileinfo_delete_at` (`DeleteAt`), - KEY `idx_fileinfo_postid_at` (`PostId`), - KEY `idx_fileinfo_extension_at` (`Extension`), - FULLTEXT KEY `idx_fileinfo_name_txt` (`Name`), - FULLTEXT KEY `idx_fileinfo_content_txt` (`Content`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `GroupChannels` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `GroupChannels` ( - `GroupId` varchar(26) NOT NULL, - `AutoAdd` tinyint(1) DEFAULT NULL, - `SchemeAdmin` tinyint(1) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `ChannelId` varchar(26) NOT NULL, - PRIMARY KEY (`GroupId`,`ChannelId`), - KEY `idx_groupchannels_schemeadmin` (`SchemeAdmin`), - KEY `idx_groupchannels_channelid` (`ChannelId`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `GroupMembers` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `GroupMembers` ( - `GroupId` varchar(26) NOT NULL, - `UserId` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - PRIMARY KEY (`GroupId`,`UserId`), - KEY `idx_groupmembers_create_at` (`CreateAt`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `GroupTeams` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `GroupTeams` ( - `GroupId` varchar(26) NOT NULL, - `AutoAdd` tinyint(1) DEFAULT NULL, - `SchemeAdmin` tinyint(1) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `TeamId` varchar(26) NOT NULL, - PRIMARY KEY (`GroupId`,`TeamId`), - KEY `idx_groupteams_schemeadmin` (`SchemeAdmin`), - KEY `idx_groupteams_teamid` (`TeamId`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `IncomingWebhooks` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `IncomingWebhooks` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `UserId` varchar(26) DEFAULT NULL, - `ChannelId` varchar(26) DEFAULT NULL, - `TeamId` varchar(26) DEFAULT NULL, - `DisplayName` varchar(64) DEFAULT NULL, - `Description` text, - `Username` varchar(255) DEFAULT NULL, - `IconURL` text, - `ChannelLocked` tinyint(1) DEFAULT NULL, - PRIMARY KEY (`Id`), - KEY `idx_incoming_webhook_user_id` (`UserId`), - KEY `idx_incoming_webhook_team_id` (`TeamId`), - KEY `idx_incoming_webhook_update_at` (`UpdateAt`), - KEY `idx_incoming_webhook_create_at` (`CreateAt`), - KEY `idx_incoming_webhook_delete_at` (`DeleteAt`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `Jobs` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Jobs` ( - `Id` varchar(26) NOT NULL, - `Type` varchar(32) DEFAULT NULL, - `Priority` bigint(20) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `StartAt` bigint(20) DEFAULT NULL, - `LastActivityAt` bigint(20) DEFAULT NULL, - `Status` varchar(32) DEFAULT NULL, - `Progress` bigint(20) DEFAULT NULL, - `Data` json DEFAULT NULL, - PRIMARY KEY (`Id`), - KEY `idx_jobs_type` (`Type`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `Licenses` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Licenses` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `Bytes` text, - PRIMARY KEY (`Id`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `LinkMetadata` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `LinkMetadata` ( - `Hash` bigint(20) NOT NULL, - `URL` text, - `Timestamp` bigint(20) DEFAULT NULL, - `Type` varchar(16) DEFAULT NULL, - `Data` json DEFAULT NULL, - PRIMARY KEY (`Hash`), - KEY `idx_link_metadata_url_timestamp` (`URL`(512),`Timestamp`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `OAuthAccessData` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `OAuthAccessData` ( - `Token` varchar(26) NOT NULL, - `RefreshToken` varchar(26) DEFAULT NULL, - `RedirectUri` text, - `ClientId` varchar(26) DEFAULT NULL, - `UserId` varchar(26) DEFAULT NULL, - `ExpiresAt` bigint(20) DEFAULT NULL, - `Scope` varchar(128) DEFAULT NULL, - PRIMARY KEY (`Token`), - UNIQUE KEY `ClientId` (`ClientId`,`UserId`), - KEY `idx_oauthaccessdata_user_id` (`UserId`), - KEY `idx_oauthaccessdata_refresh_token` (`RefreshToken`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `OAuthApps` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `OAuthApps` ( - `Id` varchar(26) NOT NULL, - `CreatorId` varchar(26) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `ClientSecret` varchar(128) DEFAULT NULL, - `Name` varchar(64) DEFAULT NULL, - `Description` text, - `CallbackUrls` text, - `Homepage` text, - `IsTrusted` tinyint(1) DEFAULT NULL, - `IconURL` text, - PRIMARY KEY (`Id`), - KEY `idx_oauthapps_creator_id` (`CreatorId`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `OAuthAuthData` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `OAuthAuthData` ( - `ClientId` varchar(26) DEFAULT NULL, - `UserId` varchar(26) DEFAULT NULL, - `Code` varchar(128) NOT NULL, - `ExpiresIn` int(11) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `RedirectUri` text, - `State` text, - `Scope` varchar(128) DEFAULT NULL, - PRIMARY KEY (`Code`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `OutgoingWebhooks` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `OutgoingWebhooks` ( - `Id` varchar(26) NOT NULL, - `Token` varchar(26) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `CreatorId` varchar(26) DEFAULT NULL, - `ChannelId` varchar(26) DEFAULT NULL, - `TeamId` varchar(26) DEFAULT NULL, - `TriggerWords` text, - `CallbackURLs` text, - `DisplayName` varchar(64) DEFAULT NULL, - `ContentType` varchar(128) DEFAULT NULL, - `TriggerWhen` int(11) DEFAULT NULL, - `Username` varchar(64) DEFAULT NULL, - `IconURL` text, - `Description` text, - PRIMARY KEY (`Id`), - KEY `idx_outgoing_webhook_team_id` (`TeamId`), - KEY `idx_outgoing_webhook_update_at` (`UpdateAt`), - KEY `idx_outgoing_webhook_create_at` (`CreateAt`), - KEY `idx_outgoing_webhook_delete_at` (`DeleteAt`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `PluginKeyValueStore` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `PluginKeyValueStore` ( - `PluginId` varchar(190) NOT NULL, - `PKey` varchar(50) NOT NULL, - `PValue` mediumblob, - `ExpireAt` bigint(20) DEFAULT NULL, - PRIMARY KEY (`PluginId`,`PKey`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `Posts` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Posts` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `UserId` varchar(26) DEFAULT NULL, - `ChannelId` varchar(26) DEFAULT NULL, - `RootId` varchar(26) DEFAULT NULL, - `OriginalId` varchar(26) DEFAULT NULL, - `Message` text, - `Type` varchar(26) DEFAULT NULL, - `Props` json DEFAULT NULL, - `Hashtags` text, - `Filenames` text, - `FileIds` text, - `HasReactions` tinyint(1) DEFAULT NULL, - `EditAt` bigint(20) DEFAULT NULL, - `IsPinned` tinyint(1) DEFAULT NULL, - `RemoteId` varchar(26) DEFAULT NULL, - PRIMARY KEY (`Id`), - KEY `idx_posts_update_at` (`UpdateAt`), - KEY `idx_posts_create_at` (`CreateAt`), - KEY `idx_posts_delete_at` (`DeleteAt`), - KEY `idx_posts_user_id` (`UserId`), - KEY `idx_posts_is_pinned` (`IsPinned`), - KEY `idx_posts_channel_id_update_at` (`ChannelId`,`UpdateAt`), - KEY `idx_posts_channel_id_delete_at_create_at` (`ChannelId`,`DeleteAt`,`CreateAt`), - KEY `idx_posts_root_id_delete_at` (`RootId`,`DeleteAt`), - FULLTEXT KEY `idx_posts_message_txt` (`Message`), - FULLTEXT KEY `idx_posts_hashtags_txt` (`Hashtags`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `Preferences` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Preferences` ( - `UserId` varchar(26) NOT NULL, - `Category` varchar(32) NOT NULL, - `Name` varchar(32) NOT NULL, - `Value` text, - PRIMARY KEY (`UserId`,`Category`,`Name`), - KEY `idx_preferences_category` (`Category`), - KEY `idx_preferences_name` (`Name`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `ProductNoticeViewState` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `ProductNoticeViewState` ( - `UserId` varchar(26) NOT NULL, - `NoticeId` varchar(26) NOT NULL, - `Viewed` int(11) DEFAULT NULL, - `Timestamp` bigint(20) DEFAULT NULL, - PRIMARY KEY (`UserId`,`NoticeId`), - KEY `idx_notice_views_timestamp` (`Timestamp`), - KEY `idx_notice_views_notice_id` (`NoticeId`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `PublicChannels` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `PublicChannels` ( - `Id` varchar(26) NOT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `TeamId` varchar(26) DEFAULT NULL, - `DisplayName` varchar(64) DEFAULT NULL, - `Name` varchar(64) DEFAULT NULL, - `Header` text, - `Purpose` varchar(250) DEFAULT NULL, - PRIMARY KEY (`Id`), - UNIQUE KEY `Name` (`Name`,`TeamId`), - KEY `idx_publicchannels_team_id` (`TeamId`), - KEY `idx_publicchannels_delete_at` (`DeleteAt`), - FULLTEXT KEY `idx_publicchannels_search_txt` (`Name`,`DisplayName`,`Purpose`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `Reactions` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Reactions` ( - `UserId` varchar(26) NOT NULL, - `PostId` varchar(26) NOT NULL, - `EmojiName` varchar(64) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `RemoteId` varchar(26) DEFAULT NULL, - PRIMARY KEY (`PostId`,`UserId`,`EmojiName`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `RemoteClusters` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `RemoteClusters` ( - `RemoteId` varchar(26) NOT NULL, - `RemoteTeamId` varchar(26) DEFAULT NULL, - `Name` varchar(64) NOT NULL, - `DisplayName` varchar(64) DEFAULT NULL, - `SiteURL` text, - `CreateAt` bigint(20) DEFAULT NULL, - `LastPingAt` bigint(20) DEFAULT NULL, - `Token` varchar(26) DEFAULT NULL, - `RemoteToken` varchar(26) DEFAULT NULL, - `Topics` text, - `CreatorId` varchar(26) DEFAULT NULL, - PRIMARY KEY (`RemoteId`,`Name`), - UNIQUE KEY `remote_clusters_site_url_unique` (`RemoteTeamId`,`SiteURL`(168)) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `RetentionPolicies` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `RetentionPolicies` ( - `Id` varchar(26) NOT NULL, - `DisplayName` varchar(64) DEFAULT NULL, - `PostDuration` bigint(20) DEFAULT NULL, - PRIMARY KEY (`Id`), - KEY `IDX_RetentionPolicies_DisplayName` (`DisplayName`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `RetentionPoliciesChannels` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `RetentionPoliciesChannels` ( - `PolicyId` varchar(26) DEFAULT NULL, - `ChannelId` varchar(26) NOT NULL, - PRIMARY KEY (`ChannelId`), - KEY `IDX_RetentionPoliciesChannels_PolicyId` (`PolicyId`), - CONSTRAINT `FK_RetentionPoliciesChannels_RetentionPolicies` FOREIGN KEY (`PolicyId`) REFERENCES `RetentionPolicies` (`Id`) ON DELETE CASCADE -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `RetentionPoliciesTeams` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `RetentionPoliciesTeams` ( - `PolicyId` varchar(26) DEFAULT NULL, - `TeamId` varchar(26) NOT NULL, - PRIMARY KEY (`TeamId`), - KEY `IDX_RetentionPoliciesTeams_PolicyId` (`PolicyId`), - CONSTRAINT `FK_RetentionPoliciesTeams_RetentionPolicies` FOREIGN KEY (`PolicyId`) REFERENCES `RetentionPolicies` (`Id`) ON DELETE CASCADE -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `Roles` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Roles` ( - `Id` varchar(26) NOT NULL, - `Name` varchar(64) DEFAULT NULL, - `DisplayName` varchar(128) DEFAULT NULL, - `Description` text, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `Permissions` longtext, - `SchemeManaged` tinyint(1) DEFAULT NULL, - `BuiltIn` tinyint(1) DEFAULT NULL, - PRIMARY KEY (`Id`), - UNIQUE KEY `Name` (`Name`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `Schemes` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Schemes` ( - `Id` varchar(26) NOT NULL, - `Name` varchar(64) DEFAULT NULL, - `DisplayName` varchar(128) DEFAULT NULL, - `Description` text, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `Scope` varchar(32) DEFAULT NULL, - `DefaultTeamAdminRole` varchar(64) DEFAULT NULL, - `DefaultTeamUserRole` varchar(64) DEFAULT NULL, - `DefaultChannelAdminRole` varchar(64) DEFAULT NULL, - `DefaultChannelUserRole` varchar(64) DEFAULT NULL, - `DefaultTeamGuestRole` varchar(64) DEFAULT NULL, - `DefaultChannelGuestRole` varchar(64) DEFAULT NULL, - PRIMARY KEY (`Id`), - UNIQUE KEY `Name` (`Name`), - KEY `idx_schemes_channel_guest_role` (`DefaultChannelGuestRole`), - KEY `idx_schemes_channel_user_role` (`DefaultChannelUserRole`), - KEY `idx_schemes_channel_admin_role` (`DefaultChannelAdminRole`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `Sessions` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Sessions` ( - `Id` varchar(26) NOT NULL, - `Token` varchar(26) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `ExpiresAt` bigint(20) DEFAULT NULL, - `LastActivityAt` bigint(20) DEFAULT NULL, - `UserId` varchar(26) DEFAULT NULL, - `DeviceId` text, - `Roles` varchar(64) DEFAULT NULL, - `IsOAuth` tinyint(1) DEFAULT NULL, - `Props` json DEFAULT NULL, - `ExpiredNotify` tinyint(1) DEFAULT NULL, - PRIMARY KEY (`Id`), - KEY `idx_sessions_user_id` (`UserId`), - KEY `idx_sessions_token` (`Token`), - KEY `idx_sessions_expires_at` (`ExpiresAt`), - KEY `idx_sessions_create_at` (`CreateAt`), - KEY `idx_sessions_last_activity_at` (`LastActivityAt`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `SharedChannelAttachments` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `SharedChannelAttachments` ( - `Id` varchar(26) NOT NULL, - `FileId` varchar(26) DEFAULT NULL, - `RemoteId` varchar(26) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `LastSyncAt` bigint(20) DEFAULT NULL, - PRIMARY KEY (`Id`), - UNIQUE KEY `FileId` (`FileId`,`RemoteId`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `SharedChannelRemotes` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `SharedChannelRemotes` ( - `Id` varchar(26) NOT NULL, - `ChannelId` varchar(26) NOT NULL, - `CreatorId` varchar(26) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `IsInviteAccepted` tinyint(1) DEFAULT NULL, - `IsInviteConfirmed` tinyint(1) DEFAULT NULL, - `RemoteId` varchar(26) DEFAULT NULL, - `LastPostUpdateAt` bigint(20) DEFAULT NULL, - `LastPostId` varchar(26) DEFAULT NULL, - PRIMARY KEY (`Id`,`ChannelId`), - UNIQUE KEY `ChannelId` (`ChannelId`,`RemoteId`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `SharedChannelUsers` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `SharedChannelUsers` ( - `Id` varchar(26) NOT NULL, - `UserId` varchar(26) DEFAULT NULL, - `RemoteId` varchar(26) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `LastSyncAt` bigint(20) DEFAULT NULL, - `ChannelId` varchar(26) DEFAULT NULL, - PRIMARY KEY (`Id`), - UNIQUE KEY `UserId` (`UserId`,`ChannelId`,`RemoteId`), - KEY `idx_sharedchannelusers_remote_id` (`RemoteId`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `SharedChannels` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `SharedChannels` ( - `ChannelId` varchar(26) NOT NULL, - `TeamId` varchar(26) DEFAULT NULL, - `Home` tinyint(1) DEFAULT NULL, - `ReadOnly` tinyint(1) DEFAULT NULL, - `ShareName` varchar(64) DEFAULT NULL, - `ShareDisplayName` varchar(64) DEFAULT NULL, - `SharePurpose` varchar(250) DEFAULT NULL, - `ShareHeader` text, - `CreatorId` varchar(26) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `RemoteId` varchar(26) DEFAULT NULL, - PRIMARY KEY (`ChannelId`), - UNIQUE KEY `ShareName` (`ShareName`,`TeamId`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `SidebarCategories` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `SidebarCategories` ( - `Id` varchar(128) NOT NULL, - `UserId` varchar(26) DEFAULT NULL, - `TeamId` varchar(26) DEFAULT NULL, - `SortOrder` bigint(20) DEFAULT NULL, - `Sorting` varchar(64) DEFAULT NULL, - `Type` varchar(64) DEFAULT NULL, - `DisplayName` varchar(64) DEFAULT NULL, - `Muted` tinyint(1) DEFAULT NULL, - `Collapsed` tinyint(1) DEFAULT NULL, - PRIMARY KEY (`Id`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `SidebarChannels` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `SidebarChannels` ( - `ChannelId` varchar(26) NOT NULL, - `UserId` varchar(26) NOT NULL, - `CategoryId` varchar(128) NOT NULL, - `SortOrder` bigint(20) DEFAULT NULL, - PRIMARY KEY (`ChannelId`,`UserId`,`CategoryId`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `Status` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Status` ( - `UserId` varchar(26) NOT NULL, - `Status` varchar(32) DEFAULT NULL, - `Manual` tinyint(1) DEFAULT NULL, - `LastActivityAt` bigint(20) DEFAULT NULL, - `DNDEndTime` bigint(20) DEFAULT NULL, - `PrevStatus` varchar(32) DEFAULT NULL, - PRIMARY KEY (`UserId`), - KEY `idx_status_status_dndendtime` (`Status`,`DNDEndTime`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `Systems` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Systems` ( - `Name` varchar(64) NOT NULL, - `Value` text, - PRIMARY KEY (`Name`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `TeamMembers` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `TeamMembers` ( - `TeamId` varchar(26) NOT NULL, - `UserId` varchar(26) NOT NULL, - `Roles` varchar(64) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `SchemeUser` tinyint(4) DEFAULT NULL, - `SchemeAdmin` tinyint(4) DEFAULT NULL, - `SchemeGuest` tinyint(4) DEFAULT NULL, - PRIMARY KEY (`TeamId`,`UserId`), - KEY `idx_teammembers_user_id` (`UserId`), - KEY `idx_teammembers_delete_at` (`DeleteAt`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `Teams` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Teams` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `DisplayName` varchar(64) DEFAULT NULL, - `Name` varchar(64) DEFAULT NULL, - `Description` varchar(255) DEFAULT NULL, - `Email` varchar(128) DEFAULT NULL, - `Type` varchar(255) DEFAULT NULL, - `CompanyName` varchar(64) DEFAULT NULL, - `AllowedDomains` text, - `InviteId` varchar(32) DEFAULT NULL, - `SchemeId` varchar(26) DEFAULT NULL, - `AllowOpenInvite` tinyint(1) DEFAULT NULL, - `LastTeamIconUpdate` bigint(20) DEFAULT NULL, - `GroupConstrained` tinyint(1) DEFAULT NULL, - PRIMARY KEY (`Id`), - UNIQUE KEY `Name` (`Name`), - KEY `idx_teams_invite_id` (`InviteId`), - KEY `idx_teams_update_at` (`UpdateAt`), - KEY `idx_teams_create_at` (`CreateAt`), - KEY `idx_teams_delete_at` (`DeleteAt`), - KEY `idx_teams_scheme_id` (`SchemeId`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `TermsOfService` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `TermsOfService` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UserId` varchar(26) DEFAULT NULL, - `Text` text, - PRIMARY KEY (`Id`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `ThreadMemberships` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `ThreadMemberships` ( - `PostId` varchar(26) NOT NULL, - `UserId` varchar(26) NOT NULL, - `Following` tinyint(1) DEFAULT NULL, - `LastViewed` bigint(20) DEFAULT NULL, - `LastUpdated` bigint(20) DEFAULT NULL, - `UnreadMentions` bigint(20) DEFAULT NULL, - PRIMARY KEY (`PostId`,`UserId`), - KEY `idx_thread_memberships_last_update_at` (`LastUpdated`), - KEY `idx_thread_memberships_last_view_at` (`LastViewed`), - KEY `idx_thread_memberships_user_id` (`UserId`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `Threads` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Threads` ( - `PostId` varchar(26) NOT NULL, - `ReplyCount` bigint(20) DEFAULT NULL, - `LastReplyAt` bigint(20) DEFAULT NULL, - `Participants` json DEFAULT NULL, - `ChannelId` varchar(26) DEFAULT NULL, - PRIMARY KEY (`PostId`), - KEY `idx_threads_channel_id_last_reply_at` (`ChannelId`,`LastReplyAt`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `Tokens` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Tokens` ( - `Token` varchar(64) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `Type` varchar(64) DEFAULT NULL, - `Extra` text, - PRIMARY KEY (`Token`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `UploadSessions` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `UploadSessions` ( - `Id` varchar(26) NOT NULL, - `Type` varchar(32) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UserId` varchar(26) DEFAULT NULL, - `ChannelId` varchar(26) DEFAULT NULL, - `Filename` text, - `Path` text, - `FileSize` bigint(20) DEFAULT NULL, - `FileOffset` bigint(20) DEFAULT NULL, - `RemoteId` varchar(26) DEFAULT NULL, - `ReqFileId` varchar(26) DEFAULT NULL, - PRIMARY KEY (`Id`), - KEY `idx_uploadsessions_user_id` (`UserId`), - KEY `idx_uploadsessions_create_at` (`CreateAt`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `UserAccessTokens` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `UserAccessTokens` ( - `Id` varchar(26) NOT NULL, - `Token` varchar(26) DEFAULT NULL, - `UserId` varchar(26) DEFAULT NULL, - `Description` text, - `IsActive` tinyint(1) DEFAULT NULL, - PRIMARY KEY (`Id`), - UNIQUE KEY `Token` (`Token`), - KEY `idx_user_access_tokens_user_id` (`UserId`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `UserGroups` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `UserGroups` ( - `Id` varchar(26) NOT NULL, - `Name` varchar(64) DEFAULT NULL, - `DisplayName` varchar(128) DEFAULT NULL, - `Description` text, - `Source` varchar(64) DEFAULT NULL, - `RemoteId` varchar(48) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `AllowReference` tinyint(1) DEFAULT NULL, - PRIMARY KEY (`Id`), - UNIQUE KEY `Name` (`Name`), - UNIQUE KEY `Source` (`Source`,`RemoteId`), - KEY `idx_usergroups_remote_id` (`RemoteId`), - KEY `idx_usergroups_delete_at` (`DeleteAt`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `UserTermsOfService` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `UserTermsOfService` ( - `UserId` varchar(26) NOT NULL, - `TermsOfServiceId` varchar(26) DEFAULT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - PRIMARY KEY (`UserId`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `Users` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `Users` ( - `Id` varchar(26) NOT NULL, - `CreateAt` bigint(20) DEFAULT NULL, - `UpdateAt` bigint(20) DEFAULT NULL, - `DeleteAt` bigint(20) DEFAULT NULL, - `Username` varchar(64) DEFAULT NULL, - `Password` varchar(128) DEFAULT NULL, - `AuthData` varchar(128) DEFAULT NULL, - `AuthService` varchar(32) DEFAULT NULL, - `Email` varchar(128) DEFAULT NULL, - `EmailVerified` tinyint(1) DEFAULT NULL, - `Nickname` varchar(64) DEFAULT NULL, - `FirstName` varchar(64) DEFAULT NULL, - `LastName` varchar(64) DEFAULT NULL, - `Roles` text, - `AllowMarketing` tinyint(1) DEFAULT NULL, - `Props` json DEFAULT NULL, - `NotifyProps` json DEFAULT NULL, - `LastPasswordUpdate` bigint(20) DEFAULT NULL, - `LastPictureUpdate` bigint(20) DEFAULT NULL, - `FailedAttempts` int(11) DEFAULT NULL, - `Locale` varchar(5) DEFAULT NULL, - `MfaActive` tinyint(1) DEFAULT NULL, - `MfaSecret` varchar(128) DEFAULT NULL, - `Position` varchar(128) DEFAULT NULL, - `Timezone` json DEFAULT NULL, - `RemoteId` varchar(26) DEFAULT NULL, - PRIMARY KEY (`Id`), - UNIQUE KEY `Username` (`Username`), - UNIQUE KEY `AuthData` (`AuthData`), - UNIQUE KEY `Email` (`Email`), - KEY `idx_users_update_at` (`UpdateAt`), - KEY `idx_users_create_at` (`CreateAt`), - KEY `idx_users_delete_at` (`DeleteAt`), - FULLTEXT KEY `idx_users_all_txt` (`Username`,`FirstName`,`LastName`,`Nickname`,`Email`), - FULLTEXT KEY `idx_users_all_no_full_name_txt` (`Username`,`Nickname`,`Email`), - FULLTEXT KEY `idx_users_names_txt` (`Username`,`FirstName`,`LastName`,`Nickname`), - FULLTEXT KEY `idx_users_names_no_full_name_txt` (`Username`,`Nickname`) -); -/*!40101 SET character_set_client = @saved_cs_client */; - --- --- Table structure for table `db_migrations` --- - -/*!40101 SET @saved_cs_client = @@character_set_client */; -/*!40101 SET character_set_client = utf8 */; -CREATE TABLE `db_migrations` ( - `Version` bigint(20) NOT NULL, - `Name` varchar(64) NOT NULL, - PRIMARY KEY (`Version`) -); -/*!40101 SET character_set_client = @saved_cs_client */; -/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; - -/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; -/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; -/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; -/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; - --- Dump completed on 2021-08-31 11:45:07 diff --git a/server/scripts/mirror-docker-images.json b/server/scripts/mirror-docker-images.json index 12d04d88aee..0ed1f02c199 100644 --- a/server/scripts/mirror-docker-images.json +++ b/server/scripts/mirror-docker-images.json @@ -5,9 +5,6 @@ "13": "postgres:13@sha256:1b154a7bbf474aa1a2e67dc7c976835645fe6c3425320e7ad3f5a926d509e8fc", "14": "postgres:14@sha256:1c418702ab77adc7e84c7e726c2ab4f9cb63b8f997341ffcfab56629bab1429d" }, - "mysql": { - "8.0.32": "mysql/mysql-server:8.0.32@sha256:d6c8301b7834c5b9c2b733b10b7e630f441af7bc917c74dba379f24eeeb6a313" - }, "minio": { "RELEASE.2019-10-11T00-38-09Z-1": "minio/minio:RELEASE.2019-10-11T00-38-09Z@sha256:0d02f16a1662653f9b961211b21ed7de04bf04492f44c2b7594bacbfcc519eb5", "RELEASE.2024-06-22T05-26-45Z": "minio/minio:RELEASE.2024-06-22T05-26-45Z@sha256:dda5e13d3df07fae2c1877701998742bcbe3bbb2b9c24c18ed5b9469cc777761" diff --git a/server/scripts/mysql-migration-test.sh b/server/scripts/mysql-migration-test.sh deleted file mode 100755 index 67b3b099a0d..00000000000 --- a/server/scripts/mysql-migration-test.sh +++ /dev/null @@ -1,59 +0,0 @@ -./scripts/jq-dep-check.sh - -TMPDIR=`mktemp -d 2>/dev/null || mktemp -d -t 'tmpConfigDir'` -DUMPDIR=`mktemp -d 2>/dev/null || mktemp -d -t 'dumpDir'` -SCHEMA_VERSION=$1 - -echo "Creating databases" -docker exec mattermost-mysql mysql -uroot -pmostest -e "CREATE DATABASE migrated; CREATE DATABASE latest; GRANT ALL PRIVILEGES ON migrated.* TO mmuser; GRANT ALL PRIVILEGES ON latest.* TO mmuser" - -echo "Importing mysql dump from version ${SCHEMA_VERSION}" -docker exec -i mattermost-mysql mysql -D migrated -uroot -pmostest < $(pwd)/scripts/mattermost-mysql-$SCHEMA_VERSION.sql - -docker exec -i mattermost-mysql mysql -D migrated -uroot -pmostest -e "INSERT INTO Systems (Name, Value) VALUES ('Version', '$SCHEMA_VERSION')" - -echo "Setting up config for db migration" -cat config/config.json | \ - jq '.SqlSettings.DataSource = "mmuser:mostest@tcp(localhost:3306)/migrated?charset=utf8mb4&readTimeout=30s&writeTimeout=30s"' | \ - jq '.SqlSettings.DriverName = "mysql"' > $TMPDIR/config.json - -echo "Running the migration" -make ARGS="db migrate --config $TMPDIR/config.json" run-cli - -echo "Setting up config for fresh db setup" -cat config/config.json | \ - jq '.SqlSettings.DataSource = "mmuser:mostest@tcp(localhost:3306)/latest?charset=utf8mb4&readTimeout=30s&writeTimeout=30s"' | \ - jq '.SqlSettings.DriverName = "mysql"' > $TMPDIR/config.json - -echo "Setting up fresh db" -make ARGS="db migrate --config $TMPDIR/config.json" run-cli - -if [ "$SCHEMA_VERSION" == "5.0.0" ]; then - for i in "ChannelMembers SchemeGuest" "ChannelMembers MsgCountRoot" "ChannelMembers MentionCountRoot" "Channels TotalMsgCountRoot"; do - a=( $i ); - echo "Ignoring known MySQL mismatch: ${a[0]}.${a[1]}" - docker exec mattermost-mysql mysql -D migrated -uroot -pmostest -e "ALTER TABLE ${a[0]} DROP COLUMN ${a[1]};" - docker exec mattermost-mysql mysql -D latest -uroot -pmostest -e "ALTER TABLE ${a[0]} DROP COLUMN ${a[1]};" - done -fi - -echo "Generating dump" -docker exec mattermost-mysql mysqldump --skip-opt --no-data --compact -u root -pmostest migrated > $DUMPDIR/migrated.sql -docker exec mattermost-mysql mysqldump --skip-opt --no-data --compact -u root -pmostest latest > $DUMPDIR/latest.sql - -echo "Removing databases created for db comparison" -docker exec mattermost-mysql mysql -uroot -pmostest -e "DROP DATABASE migrated; DROP DATABASE latest" - -echo "Generating diff" -git diff --word-diff=color $DUMPDIR/migrated.sql $DUMPDIR/latest.sql > $DUMPDIR/diff.txt -diffErrorCode=$? - -if [ $diffErrorCode -eq 0 ]; then - echo "Both schemas are same" -else - echo "Schema mismatch" - cat $DUMPDIR/diff.txt -fi -rm -rf $TMPDIR $DUMPDIR - -exit $diffErrorCode diff --git a/server/scripts/replica-lag-set.sh b/server/scripts/replica-lag-set.sh deleted file mode 100755 index 4c202a73a50..00000000000 --- a/server/scripts/replica-lag-set.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/bash - -stmt="STOP SLAVE SQL_THREAD FOR CHANNEL '';CHANGE MASTER TO MASTER_DELAY = $1;START SLAVE SQL_THREAD FOR CHANNEL '';SHOW SLAVE STATUS\G;" -docker exec mattermost-mysql-read-replica sh -c "export MYSQL_PWD=mostest; mysql -u root -e \"$stmt\"" | grep SQL_Delay \ No newline at end of file diff --git a/server/scripts/replica-mysql-config.sh b/server/scripts/replica-mysql-config.sh deleted file mode 100755 index 5ef09379ff6..00000000000 --- a/server/scripts/replica-mysql-config.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash - -until docker exec mattermost-mysql sh -c 'mysql -u root -pmostest -e ";"' -do - echo "Waiting for mattermost-mysql database connection..." - sleep 4 -done - -priv_stmt='GRANT REPLICATION SLAVE ON *.* TO "mmuser"@"%" IDENTIFIED BY "mostest"; FLUSH PRIVILEGES;' -docker exec mattermost-mysql sh -c "mysql -u root -pmostest -e '$priv_stmt'" - -until docker compose -f docker-compose.makefile.yml exec mysql-read-replica sh -c 'mysql -u root -pmostest -e ";"' -do - echo "Waiting for mysql-read-replica database connection..." - sleep 4 -done - -docker-ip() { - docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$@" -} - -MS_STATUS=`docker exec mattermost-mysql sh -c 'mysql -u root -pmostest -e "SHOW MASTER STATUS"'` -CURRENT_LOG=`echo $MS_STATUS | awk '{print $6}'` -CURRENT_POS=`echo $MS_STATUS | awk '{print $7}'` - -start_slave_stmt="CHANGE MASTER TO MASTER_HOST='$(docker-ip mattermost-mysql)',MASTER_USER='mmuser',MASTER_PASSWORD='mostest',MASTER_LOG_FILE='$CURRENT_LOG',MASTER_LOG_POS=$CURRENT_POS; START SLAVE;" -start_slave_cmd='mysql -u root -pmostest -e "' -start_slave_cmd+="$start_slave_stmt" -start_slave_cmd+='"' -docker exec mattermost-mysql-read-replica sh -c "$start_slave_cmd" - -docker exec mattermost-mysql-read-replica sh -c "mysql -u root -pmostest -e 'SHOW SLAVE STATUS \G'" diff --git a/server/tests/template.load b/server/tests/template.load deleted file mode 100644 index 1c542093c5e..00000000000 --- a/server/tests/template.load +++ /dev/null @@ -1,45 +0,0 @@ -LOAD DATABASE - FROM mysql://{{ .mysql_user }}:{{ .mysql_password }}@mysql:3306/{{ .source_schema }} - INTO pgsql://{{ .pg_user }}:{{ .pg_password }}@postgres:5432/{{ .target_schema }} - -WITH data only, - workers = 8, concurrency = 1, - multiple readers per thread, rows per range = 10000, - prefetch rows = 10000, batch rows = 2500, - create no tables, create no indexes, - preserve index names - -SET PostgreSQL PARAMETERS - maintenance_work_mem to '128MB', - work_mem to '12MB' - -SET MySQL PARAMETERS - net_read_timeout = '120', - net_write_timeout = '120' - - CAST column Channels.Type to "channel_type" drop typemod, - column Teams.Type to "team_type" drop typemod, - column UploadSessions.Type to "upload_session_type" drop typemod, - column ChannelBookmarks.Type to "channel_bookmark_type" drop typemod, - column Drafts.Priority to text, - type int when (= precision 11) to integer drop typemod, - type bigint when (= precision 20) to bigint drop typemod, - type text to varchar drop typemod using remove-null-characters, - type tinyint when (<= precision 4) to boolean using tinyint-to-boolean, - type json to jsonb drop typemod using remove-null-characters - -EXCLUDING TABLE NAMES MATCHING ~, ~ - -BEFORE LOAD DO - $$ ALTER SCHEMA public RENAME TO {{ .source_schema }}; $$, - $$ TRUNCATE TABLE {{ .source_schema }}.systems; $$, - $$ DROP INDEX IF EXISTS {{ .source_schema }}.idx_posts_message_txt; $$, - $$ DROP INDEX IF EXISTS {{ .source_schema }}.idx_fileinfo_content_txt; $$ - -AFTER LOAD DO - $$ UPDATE {{ .source_schema }}.db_migrations set name='add_createat_to_teamembers' where version=92; $$, - $$ CREATE INDEX IF NOT EXISTS idx_posts_message_txt ON {{ .source_schema }}.posts USING gin(to_tsvector('english', message)); $$, - $$ CREATE INDEX IF NOT EXISTS idx_fileinfo_content_txt ON {{ .source_schema }}.fileinfo USING gin(to_tsvector('english', content)); $$, - $$ ALTER SCHEMA {{ .source_schema }} RENAME TO public; $$, - $$ SELECT pg_catalog.set_config('search_path', '"$user", public', false); $$, - $$ ALTER USER {{ .pg_user }} SET SEARCH_PATH TO 'public'; $$; diff --git a/server/tests/test-config.json b/server/tests/test-config.json index f194f90f063..e31be182e45 100644 --- a/server/tests/test-config.json +++ b/server/tests/test-config.json @@ -89,8 +89,8 @@ "IosMinVersion": "" }, "SqlSettings": { - "DriverName": "mysql", - "DataSource": "mmuser:mostest@tcp(localhost:3306)/mattermost_test?charset=utf8mb4\u0026readTimeout=30s\u0026writeTimeout=30s\u0026maxAllowedPacket=4194304", + "DriverName": "postgres", + "DataSource": "postgres://mmuser:mostest@localhost:5432/mattermost_test?sslmode=disable\u0026connect_timeout=10", "DataSourceReplicas": [], "DataSourceSearchReplicas": [], "Trace": false, diff --git a/webapp/.npmrc b/webapp/.npmrc index 145d3fa25b8..cffe8cdef13 100644 --- a/webapp/.npmrc +++ b/webapp/.npmrc @@ -1,2 +1 @@ save-exact=true -engine-strict=true diff --git a/webapp/CLAUDE.OPTIONAL.md b/webapp/CLAUDE.OPTIONAL.md new file mode 100644 index 00000000000..1768819e4de --- /dev/null +++ b/webapp/CLAUDE.OPTIONAL.md @@ -0,0 +1,98 @@ +# CLAUDE.md + +Guidance for Claude Code when working inside `webapp/`. + +## Project Overview + +This is the Mattermost web app codebase, a React-based frontend application for the Mattermost collaboration platform. The repository is structured as an npm workspace monorepo with multiple packages, with the main application code in the `channels` package and shared platform code in `platform/*` packages. + +- **Primary workspace**: `channels/` (UI, Redux, routing). +- **Shared packages**: `platform/*`. +- **Scripts**: `webapp/scripts/` power dev server, builds, and localization flows. +- **Coding Standards**: Read `webapp/STYLE_GUIDE.md` for canonical standards; nested `CLAUDE.md` files cover directory-specific rules. + +## Core Commands + +| Task | Command | +| --- | --- | +| Install deps | `npm install` (includes postinstall build of platform packages) | +| Dev server (prod build watch) | `make run` | +| Dev server (webpack-dev-server) | `make dev` or `npm run dev-server --workspace=channels` | +| Build all workspaces | `make dist` or `npm run build` | +| Build Channels only | `npm run build --workspace=channels` | +| Tests | `make test` or `npm run test --workspace=channels` (use `test:watch`, `test:updatesnapshot` as needed) | +| Lint / Style | `make check-style`, `make fix-style`, `npm run check --workspace=channels`, `npm run fix --workspace=channels` | +| Type check | `make check-types` | +| Clean artifacts | `make clean` or `npm run clean --workspaces --if-present` | + +## Top-Level Directory Map + +- `channels/` – Channels workspace. See `channels/CLAUDE.md`. + - `src/` – App source with further scoped guides (components, actions, selectors, reducers, store, sass, i18n, tests, utils, types, plugins, packages/mattermost-redux). +- `platform/` – Shared packages (`client`, `components`, `types`, `eslint-plugin`). See `platform/CLAUDE.md` plus sub-guides. +- `scripts/` – Build/dev automation. See `scripts/CLAUDE.md`. +- `STYLE_GUIDE.md` – Authoritative style + accessibility + testing reference. +- `README.md`, `config.mk`, `Makefile` – onboarding, env config, and command wiring. + +## Workspace Architecture + +This repository uses npm workspaces: + +- **channels** (`channels/`): Main Mattermost web app containing all UI components, Redux logic, and application code +- **@mattermost/types** (`platform/types/`): TypeScript type definitions +- **@mattermost/client** (`platform/client/`): REST and WebSocket client for the Mattermost API +- **@mattermost/components** (`platform/components/`): Shared React components +- **@mattermost/eslint-plugin** (`platform/eslint-plugin/`): Custom ESLint rules + +### Importing Packages + +Always import packages using their full name, never relative paths: +```typescript +// Correct +import {Client4} from '@mattermost/client'; +import {UserProfile} from '@mattermost/types/users'; +import {getUser} from 'mattermost-redux/selectors/entities/users'; + +// Incorrect +import Client4 from '../platform/client/src/client4.ts'; +``` + +## Key Dependencies + +- **React 18.2**: Main UI framework +- **Redux 5.0**: State management +- **React Router 5.3**: Client-side routing +- **React Intl**: Internationalization +- **Floating UI**: Tooltips and popovers (prefer `WithTooltip` component) +- **@mattermost/compass-icons**: Icon library (prefer over font-awesome) +- **Monaco Editor**: Code editor integration +- **Styled Components**: Limited use (for MUI and some legacy components) + +## Important Configuration Files + +- `channels/webpack.config.js`: Webpack configuration with module federation +- `channels/jest.config.js`: Jest test configuration +- `channels/tsconfig.json`: TypeScript configuration with workspace references +- `channels/.eslintrc.json`: ESLint configuration + +## Cross-Cutting Standards & Common Gotchas + +- **Functional Components**: Prefer functional React components with hooks; memoize expensive logic. +- **Data Access**: Client4/WebSocket access happens via Redux actions only—never directly from components. +- **Internationalization**: All UI strings must be translatable via React Intl. Use `FormattedMessage` unless a raw string is required. +- **Styling**: Uses SCSS + CSS variables with BEM naming; avoid `!important` unless migrating legacy code. +- **Testing**: RTL + `userEvent` for tests; no snapshots. Use helpers under `channels/src/tests/`. +- **Accessibility**: Follow guidance in `STYLE_GUIDE.md` (semantic elements, keyboard support, focus management). +- **Platform Packages**: Rebuild automatically on `npm install`; re-run if types appear stale. +- **Adding Dependencies**: Always add dependencies with `npm add --workspace=channels` (or the relevant workspace). +- **Redux State Split**: `state.entities.*` (server data via mattermost-redux) vs `state.views.*` (UI/persisted). Store new server entities in mattermost-redux first. +- **Client4 Returns**: Methods return `{response, headers, data}` – unwrap accordingly in actions. + +## Nested CLAUDE Files + +- Channels workspace: `channels/CLAUDE.md`, `channels/src/CLAUDE.md`. +- Channels source subfolders: `components/`, `actions/`, `selectors/`, `reducers/`, `store/`, `sass/`, `i18n/`, `tests/`, `utils/`, `types/`, `plugins/`, `packages/mattermost-redux/`. +- Platform packages: `platform/CLAUDE.md`, plus `platform/client/`, `platform/components/`, `platform/types/`. +- Tooling: `scripts/CLAUDE.md`. + +Use these nested guides for focused, actionable instructions when working within each directory. diff --git a/webapp/Makefile b/webapp/Makefile index a01f59d0602..f2ae6d22ae9 100644 --- a/webapp/Makefile +++ b/webapp/Makefile @@ -63,6 +63,18 @@ check-types: node_modules ## Checks TS file for TypeScript confirmity npm run check-types +.PHONY: i18n-extract +i18n-extract: node_modules ## Extracts i18n messages from code to en.json + @echo Extracting i18n messages + + npm run i18n-extract + +.PHONY: i18n-extract-check +i18n-extract-check: node_modules ## Checks if en.json is in sync with code + @echo Checking i18n message sync + + npm run i18n-extract:check + .PHONY: dist dist: node_modules ## Builds all web app packages @echo Packaging Mattermost Web App diff --git a/webapp/channels/.eslintrc.json b/webapp/channels/.eslintrc.json index 793badea192..0505b4e282b 100644 --- a/webapp/channels/.eslintrc.json +++ b/webapp/channels/.eslintrc.json @@ -8,7 +8,10 @@ "no-only-tests" ], "settings": { - "import/resolver": "webpack" + "import/resolver": "webpack", + "formatjs": { + "additionalFunctionNames": ["localizeMessage", "defineMessage"] + } }, "rules": { "react/prop-types": [ @@ -32,7 +35,16 @@ "react/style-prop-object": [2, { "allow": ["Timestamp"] }], + "formatjs/enforce-default-message": 2, + "formatjs/enforce-id": 2, + "formatjs/enforce-placeholders": 2, + "formatjs/no-invalid-icu": 2, + "formatjs/no-multiple-plurals": 1, "formatjs/no-multiple-whitespaces": 2, + "formatjs/no-literal-string-in-jsx": 1, + "formatjs/prefer-formatted-message": 1, + "formatjs/no-useless-message": 1, + "formatjs/prefer-pound-in-plural": 0, "react/jsx-fragments": ["error", "syntax"] }, "overrides": [ diff --git a/webapp/channels/.github/workflows/performance-benchmarks.yml b/webapp/channels/.github/workflows/performance-benchmarks.yml index 9360a7ad30c..f20e4c3eeb1 100644 --- a/webapp/channels/.github/workflows/performance-benchmarks.yml +++ b/webapp/channels/.github/workflows/performance-benchmarks.yml @@ -23,9 +23,9 @@ jobs: - name: Check out web app uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - - uses: actions/setup-node@cdca7365b2dadb8aad0a33bc7601856ffabcc48e # v4.3.0 + - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: - node-version: 16.10.0 + node-version-file: ".nvmrc" - name: Download and install Cypress uses: cypress-io/github-action@108b8684ae52e735ff7891524cbffbcd4be5b19f # v6.7.16 diff --git a/webapp/channels/.gitignore b/webapp/channels/.gitignore index c1c7faa7e23..f9f49334830 100644 --- a/webapp/channels/.gitignore +++ b/webapp/channels/.gitignore @@ -24,3 +24,6 @@ Session.vim *~ .eslintcache .stylelintcache + +# i18n temporary check file +.i18n-check.tmp.json diff --git a/webapp/channels/CLAUDE.OPTIONAL.md b/webapp/channels/CLAUDE.OPTIONAL.md new file mode 100644 index 00000000000..037fc9d9ee6 --- /dev/null +++ b/webapp/channels/CLAUDE.OPTIONAL.md @@ -0,0 +1,73 @@ +# CLAUDE: `webapp/channels/` + +## Purpose +- Main Mattermost web client workspace; almost every UI or Redux change flows through this package. +- Runs as an npm workspace – use `--workspace=channels` when installing deps or running scripts. +- Builds a federated bundle consumed by the server and plugins. + +## Local Commands +- `npm run dev-server --workspace=channels` – hot-reload development server. +- `npm run build --workspace=channels` – production bundle (invokes webpack config in this folder). +- `npm run test --workspace=channels` / `npm run test:watch --workspace=channels`. +- `npm run check --workspace=channels` and `npm run fix --workspace=channels` for lint/style fixes. + +## Directory Structure (src/) + +``` +src/ +├── components/ # React components organized by feature (300+ subdirectories) +├── actions/ # Redux action creators (sync and async thunks) +├── selectors/ # Redux selectors for deriving state +├── reducers/ # Redux reducers for state management +├── utils/ # Utility functions and helpers +├── tests/ # Test utilities and helpers +├── i18n/ # Internationalization files +├── sass/ # Global SCSS styles and theme variables +├── types/ # TypeScript type definitions specific to the web app +├── store/ # Redux store configuration with redux-persist +├── plugins/ # Plugin integration points +├── packages/ +│ └── mattermost-redux/ # Core Redux layer (actions, reducers, selectors) +├── entry.tsx # Application entry point +└── root.tsx # Root React component +``` + +## State Management +- **Redux + Redux Thunk**: Central state management using Redux with thunk middleware for async actions. +- **Redux Persist**: State persistence using localForage with cross-tab synchronization. +- **Mattermost Redux**: Core Redux logic (`state.entities.*` for server data). +- **State Views**: `state.views.*` for UI state (modals, sidebars, preferences). +- **Client4**: Singleton HTTP client for API requests. Should only be used in Redux actions. + +## Key Files +- `package.json` – workspace-specific scripts, env vars, and browserlist targets. +- `webpack.config.js` – module federation + alias map; update remotes or exposes here only when necessary. +- `jest.config.js` – test roots, transformers, moduleNameMapper for workspace aliases. +- `tsconfig.json` – project references for `src`, `tests`, and embedded packages. + +## TypeScript Configuration +- **Strict Mode**: TypeScript strict mode enabled with `strictNullChecks` +- **Path Aliases**: Configured for `@mattermost/*` packages and `mattermost-redux/*` +- **Composite Projects**: Uses TypeScript project references for workspace packages +- **No Any**: Avoid `any` types; legacy code may have them but new code should be typed + +## Module Federation Notes +- Use `channels/src/module_registry.ts` to register async chunks; never import plugin remotes synchronously. +- Exposed modules must stay backward compatible; document any break in `webapp/README.md`. +- When adding a new remote, coordinate with server config (see `webpack.config.js` → `remotes`). +- Prefer wrapping plugin surfaces in adapter components so that federated boundaries remain stable. + +## Dependencies & UI Stack +- React 18, Redux 5, React Router 5, React Intl, Floating UI, Compass Icons, Monaco. +- Follow `webapp/STYLE_GUIDE.md → Dependencies & Packages` before introducing new libs. +- `@mattermost/types`, `@mattermost/client`, and `platform/components` are first-party packages; import via full package names, not deep relative paths. + +## Common Gotchas +- Postinstall builds platform packages—if TypeScript types feel stale, re-run `npm install` at repo root. +- Use `npm add --workspace=channels` to avoid polluting other workspaces. +- Environment-specific overrides live in `config/` on the server side; do not hard-code URLs or feature flags here. +- Webpack aliases mirror tsconfig paths; keep both in sync when adding a new alias. + +## References +- `webapp/STYLE_GUIDE.md → Automated Style Checking`, `Dependencies & Packages`. +- `webapp/README.md` for high-level architecture and release info. diff --git a/webapp/channels/package.json b/webapp/channels/package.json index b1ca6c63b74..2bb26e218e3 100644 --- a/webapp/channels/package.json +++ b/webapp/channels/package.json @@ -3,16 +3,16 @@ "browser": { "./client/web_client.jsx": "./client/browser_web_client.jsx" }, - "version": "11.3.0", + "version": "11.4.0", "private": true, "dependencies": { "@floating-ui/react": "0.26.6", "@giphy/js-fetch-api": "5.7.0", "@giphy/react-components": "10.1.0", "@guyplusplus/turndown-plugin-gfm": "1.0.7", - "@mattermost/client": "11.3.0", + "@mattermost/client": "11.4.0", "@mattermost/desktop-api": "6.0.0-1", - "@mattermost/types": "11.3.0", + "@mattermost/types": "11.4.0", "@mui/base": "5.0.0-alpha.127", "@mui/material": "5.11.16", "@mui/styled-engine-sc": "5.11.11", @@ -99,7 +99,6 @@ "@deanwhillier/jest-matchmedia-mock": "1.2.0", "@mattermost/calls-common": "0.27.0", "@mattermost/eslint-plugin": "*", - "@mattermost/mmjstool": "github:mattermost/mattermost-utilities#7b63833d208d482ba4a1c12230bb3e68dd9c5e5e", "@stylistic/stylelint-plugin": "3.1.2", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "6.8.0", @@ -150,8 +149,8 @@ "imagemin-mozjpeg": "9.0.0", "jest": "30.1.3", "jest-canvas-mock": "2.5.0", - "jest-cli": "29.7.0", - "jest-environment-jsdom": "29.7.0", + "jest-cli": "30.1.3", + "jest-environment-jsdom": "30.1.0", "jest-junit": "16.0.0", "jest-watch-typeahead": "3.0.1", "nock": "13.2.8", @@ -188,10 +187,8 @@ "test-ci": "cross-env TZ=Etc/UTC LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 jest --ci --maxWorkers=100% --forceExit --detectOpenHandles --logHeapUsage --coverage", "clean": "rm -rf dist node_modules .eslintcache .stylelintcache tsconfig.tsbuildinfo", "stats": "cross-env NODE_ENV=production webpack --profile --json > webpack_stats.json", - "mmjstool": "mmjstool", - "i18n-extract": "npm run mmjstool -- i18n extract-webapp --webapp-dir ./src", - "i18n-clean-empty": "npm run mmjstool -- i18n clean-empty --webapp-dir ./src", - "i18n-check-empty-src": "npm run mmjstool -- i18n check-empty-src --webapp-dir ./src", + "i18n-extract": "formatjs extract 'src/**/*.{js,jsx,ts,tsx}' --additional-function-names localizeMessage --ignore 'src/**/*.d.ts' --ignore 'src/**/*.test.{js,jsx,ts,tsx}' --out-file src/i18n/en.json --format scripts/formatter.js --id-interpolation-pattern '[sha512:contenthash:base64:6]' --preserve-whitespace", + "i18n-extract:check": "npm run i18n-extract -- --throws --out-file .i18n-check.tmp.json && diff src/i18n/en.json .i18n-check.tmp.json && rm -f .i18n-check.tmp.json || (rm -f .i18n-check.tmp.json && echo '\nen.json is out of sync with code. \nTo update: npm run i18n-extract' && exit 1)", "check-types": "tsc -b", "make-emojis": "node --experimental-json-modules build/emoji/make_emojis.mjs" } diff --git a/webapp/channels/scripts/formatter.js b/webapp/channels/scripts/formatter.js new file mode 100644 index 00000000000..c7f170a612c --- /dev/null +++ b/webapp/channels/scripts/formatter.js @@ -0,0 +1,45 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/** + * Custom formatjs formatter matching mmjstool's behavior + * + * Based on formatjs simple formatter with case-insensitive sorting + * to match mmjstool's sortJson({ignoreCase: true}) + */ + +/** + * Format function - extract defaultMessage from message objects + * Same as formatjs simple formatter + */ +module.exports.format = (msgs) => { + return Object.keys(msgs).reduce((all, k) => { + all[k] = msgs[k].defaultMessage; + return all; + }, {}); +}; + +/** + * Compile function - pass through (identity) + * Same as formatjs simple formatter + */ +module.exports.compile = (msgs) => msgs; + +/** + * Custom comparator for case-insensitive alphabetical sorting + * with underscore before dot (to match existing en.json ordering) + */ +module.exports.compareMessages = (el1, el2) => { + // Normalize keys: replace _ with a character that sorts before . + // Use \x00 (null char) which sorts before all printable characters + const key1 = el1.key.toLowerCase().replace(/_/g, '\x00'); + const key2 = el2.key.toLowerCase().replace(/_/g, '\x00'); + + if (key1 < key2) { + return -1; + } + if (key1 > key2) { + return 1; + } + return 0; +}; diff --git a/webapp/channels/src/CLAUDE.OPTIONAL.md b/webapp/channels/src/CLAUDE.OPTIONAL.md new file mode 100644 index 00000000000..bac763977d3 --- /dev/null +++ b/webapp/channels/src/CLAUDE.OPTIONAL.md @@ -0,0 +1,36 @@ +# CLAUDE: `channels/src/` + +## Purpose +- React + Redux source for the Channels app. Everything rendered in the browser lives here. +- Split by concern: UI (`components`, `sass`), data (`actions`, `reducers`, `selectors`, `store`), utilities, and feature-specific packages. + +## Directory Map +- `components/` – feature folders for UI (see `components/CLAUDE.md`). +- `actions/`, `reducers/`, `selectors/`, `store/` – Redux stack (each has its own CLAUDE). +- `sass/` – theme variables and global styles. +- `i18n/` – locale JSON plus helpers. +- `utils/`, `types/` – shared helpers + local type definitions. +- `packages/mattermost-redux/` – embedded redux package mirroring the standalone repo. + +## Layering Rules +- Components never call `Client4` directly; async work flows through `actions` → `mattermost-redux` → API packages. +- Shared state comes from `mattermost-redux/state.entities.*`; UI/persisted state belongs in `state.views.*`. +- Prefer hooks (`useSelector`, `useDispatch`, custom hooks) over legacy HOCs. +- Keep cross-layer imports stable: `components` may import `selectors`, `utils`, `types`, but not `reducers` or `store`. + +## State Management Primer +- Redux store configured in `store/index.ts`; persistence handled via redux-persist + localForage. +- Selector factories (`makeGet...`) should be memoized per component instance. +- Use `mattermost-redux` for server-backed data; add new entity fields there first, then expose selectors into this workspace. + +## How to Navigate +- Start from route entry (`root.tsx` and `root.html`) to understand bootstrapping and async chunk loading. +- `module_registry.ts` registers dynamically loaded views; ensure new routes/components are wrapped with `makeAsyncComponent` where appropriate. +- Before adding a new folder, check for an existing feature area under `components` or `utils`. + +## References +- `webapp/STYLE_GUIDE.md → React Component Structure`, `Redux & Data Fetching`. +- Example: `root.tsx` (bootstrapping), `module_registry.ts` (async component wiring). + + + diff --git a/webapp/channels/src/actions/CLAUDE.OPTIONAL.md b/webapp/channels/src/actions/CLAUDE.OPTIONAL.md new file mode 100644 index 00000000000..0bfa19f27ad --- /dev/null +++ b/webapp/channels/src/actions/CLAUDE.OPTIONAL.md @@ -0,0 +1,76 @@ +# CLAUDE: `actions/` + +## Purpose +- Hosts Redux action creators (sync + thunk) for UI behaviors and server calls specific to the Channels webapp. +- Bridges components to `mattermost-redux` and `@mattermost/client`. + +## Directory Structure + +``` +actions/ +├── *.ts # Domain-specific actions (channel_actions.ts, post_actions.ts, etc.) +└── views/ # UI-specific actions (modals, sidebars, etc.) +``` + +## Action Patterns + +### Async Thunks + +All async thunks must return `{data: ...}` on success or `{error: ...}` on failure. + +```typescript +export function fetchSomething(id: string): ActionFuncAsync { + return async (dispatch, getState) => { + try { + const data = await Client4.getSomething(id); + dispatch({type: ActionTypes.RECEIVED_SOMETHING, data}); + return {data}; + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error}; + } + }; +} +``` + +### Using bindClientFunc + +For simple API calls, use `bindClientFunc` helper for standard error handling: + +```typescript +export function fetchUser(userId: string): ActionFuncAsync { + return bindClientFunc({ + clientFunc: Client4.getUser, + params: [userId], + onSuccess: ActionTypes.RECEIVED_USER, + }); +} +``` + +## Conventions & Best Practices +- **Response Structure**: Async actions return `{data}` on success or `{error}` on failure (see `webapp/STYLE_GUIDE.md → Redux & Data Fetching`). +- **Actions Only**: Call `Client4` only inside actions; components should dispatch actions, never hit APIs directly. +- **Helpers**: Extract reusable async logic into helpers (`hooks.ts`, `apps.ts`) rather than duplicating inside multiple actions. +- **Entity Data**: When adding new entity data, first wire it through `channels/src/packages/mattermost-redux`, then consume selectors here. + +## Error & Logging Requirements +- Catch errors to call `forceLogoutIfNecessary(error)` and dispatch `logError`. +- Use telemetry wrappers (`trackEvent`, `perf`) when adding analytics inside thunks. +- Always dispatch optimistic UI updates with corresponding failure rollback where user experience demands it. + +## Batching Network Requests +- Use bulk API endpoints when available. +- Use `DelayedDataLoader` for batching multiple calls. +- Fetch data from parent components, not individual list items. + +## views/ Subdirectory +UI state actions that don't involve server data (modals, sidebars, view state) dispatch to `state.views.*` reducers rather than `state.entities.*`. + +## Testing +- Favor RTL-style async action tests with mocked store where possible (`channel_actions.test.ts`). +- Use `nock` or request-mocking utilities in `mattermost-redux` tests for complex flows. + +## References +- `channel_actions.ts`, `global_actions.tsx` – canonical patterns for async thunks. +- `mattermost-redux/src/actions/*` – shared actions; import instead of duplicating server logic. diff --git a/webapp/channels/src/actions/apps.ts b/webapp/channels/src/actions/apps.ts index 1ebf45504fd..bd2b495cfa5 100644 --- a/webapp/channels/src/actions/apps.ts +++ b/webapp/channels/src/actions/apps.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type {IntlShape} from 'react-intl'; import type {AnyAction} from 'redux'; import type {AppCallResponse, AppForm, AppCallRequest, AppContext, AppBinding} from '@mattermost/types/apps'; @@ -76,7 +77,7 @@ export function handleBindingClick(binding: AppBinding, context: Ap }; } -export function doAppSubmit(inCall: AppCallRequest, intl: any): ThunkActionFunc>> { +export function doAppSubmit(inCall: AppCallRequest, intl: IntlShape): ThunkActionFunc>> { return async () => { try { const call: AppCallRequest = { @@ -98,7 +99,7 @@ export function doAppSubmit(inCall: AppCallRequest, intl: any): Thu if (!res.form?.submit) { const errMsg = intl.formatMessage({ id: 'apps.error.responses.form.no_form', - defaultMessage: 'Response type is `form`, but no valid form was included in response.', + defaultMessage: 'Response type is `form`, but no form was included in response.', }); return {error: makeCallErrorResponse(errMsg)}; } @@ -153,7 +154,7 @@ export function doAppFetchForm(call: AppCallRequest, intl: any): Th if (!res.form?.submit) { const errMsg = intl.formatMessage({ id: 'apps.error.responses.form.no_form', - defaultMessage: 'Response type is `form`, but no valid form was included in response.', + defaultMessage: 'Response type is `form`, but no form was included in response.', }); return {error: makeCallErrorResponse(errMsg)}; } diff --git a/webapp/channels/src/actions/command.test.js b/webapp/channels/src/actions/command.test.js index 2aa719a0f15..17111484722 100644 --- a/webapp/channels/src/actions/command.test.js +++ b/webapp/channels/src/actions/command.test.js @@ -168,7 +168,7 @@ describe('executeCommand', () => { expect(result).toEqual({ error: { - message: 'Keyboard shortcuts are not supported on your device', + message: 'Keyboard shortcuts are not supported on your device.', }, }); }); diff --git a/webapp/channels/src/actions/command.ts b/webapp/channels/src/actions/command.ts index 446ebcea2df..54a7c4b4d5d 100644 --- a/webapp/channels/src/actions/command.ts +++ b/webapp/channels/src/actions/command.ts @@ -25,14 +25,14 @@ import KeyboardShortcutsModal from 'components/keyboard_shortcuts/keyboard_short import LeaveChannelModal from 'components/leave_channel_modal'; import MarketplaceModal from 'components/plugin_marketplace/marketplace_modal'; import {AppCommandParser} from 'components/suggestion/command_provider/app_command_parser/app_command_parser'; -import {intlShim} from 'components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies'; import UserSettingsModal from 'components/user_settings/modal'; import {getHistory} from 'utils/browser_history'; import {Constants, ModalIdentifiers} from 'utils/constants'; +import {getIntl} from 'utils/i18n'; import {isUrlSafe, getSiteURL} from 'utils/url'; import * as UserAgent from 'utils/user_agent'; -import {localizeMessage, getUserIdFromChannelName} from 'utils/utils'; +import {getUserIdFromChannelName} from 'utils/utils'; import type {ActionFuncAsync} from 'types/store'; @@ -47,6 +47,7 @@ export type ExecuteCommandReturnType = { export function executeCommand(message: string, args: CommandArgs): ActionFuncAsync { return async (dispatch, getState) => { + const intl = getIntl(); const state = getState(); let msg = message; @@ -64,7 +65,7 @@ export function executeCommand(message: string, args: CommandArgs): ActionFuncAs return {data: {frontendHandled: true}}; case '/shortcuts': if (UserAgent.isMobile()) { - const error = {message: localizeMessage({id: 'create_post.shortcutsNotSupported', defaultMessage: 'Keyboard shortcuts are not supported on your device'})}; + const error = {message: intl.formatMessage({id: 'create_post.shortcutsNotSupported', defaultMessage: 'Keyboard shortcuts are not supported on your device.'})}; return {error}; } @@ -118,12 +119,12 @@ export function executeCommand(message: string, args: CommandArgs): ActionFuncAs case '/marketplace': // check if user has permissions to access the read plugins if (!haveICurrentTeamPermission(state, Permissions.SYSCONSOLE_WRITE_PLUGINS)) { - return {error: {message: localizeMessage({id: 'marketplace_command.no_permission', defaultMessage: 'You do not have the appropriate permissions to access the marketplace.'})}}; + return {error: {message: intl.formatMessage({id: 'marketplace_command.no_permission', defaultMessage: 'You do not have the appropriate permissions to access the marketplace.'})}}; } // check config to see if marketplace is enabled if (!isMarketplaceEnabled(state)) { - return {error: {message: localizeMessage({id: 'marketplace_command.disabled', defaultMessage: 'The marketplace is disabled. Please contact your System Administrator for details.'})}}; + return {error: {message: intl.formatMessage({id: 'marketplace_command.disabled', defaultMessage: 'The marketplace is disabled. Please contact your System Administrator for details.'})}}; } dispatch(openModal({modalId: ModalIdentifiers.PLUGIN_MARKETPLACE, dialogType: MarketplaceModal})); @@ -139,7 +140,7 @@ export function executeCommand(message: string, args: CommandArgs): ActionFuncAs const createErrorMessage = (errMessage: string) => { return {error: {message: errMessage}}; }; - const parser = new AppCommandParser({dispatch, getState: getGlobalState} as any, intlShim, args.channel_id, args.team_id, args.root_id); + const parser = new AppCommandParser({dispatch, getState: getGlobalState} as any, intl, args.channel_id, args.team_id, args.root_id); if (parser.isAppCommand(msg)) { try { const {creq, errorMessage} = await parser.composeCommandSubmitCall(msg); @@ -147,11 +148,11 @@ export function executeCommand(message: string, args: CommandArgs): ActionFuncAs return createErrorMessage(errorMessage!); } - const res = await dispatch(doAppSubmit(creq, intlShim)); + const res = await dispatch(doAppSubmit(creq, intl)); if (res.error) { const errorResponse = res.error; - return createErrorMessage(errorResponse.text || intlShim.formatMessage({ + return createErrorMessage(errorResponse.text || intl.formatMessage({ id: 'apps.error.unknown', defaultMessage: 'Unknown error occurred.', })); @@ -172,7 +173,7 @@ export function executeCommand(message: string, args: CommandArgs): ActionFuncAs case AppCallResponseTypes.NAVIGATE: return {data: {appResponse: callResp}}; default: - return createErrorMessage(intlShim.formatMessage( + return createErrorMessage(intl.formatMessage( { id: 'apps.error.responses.unknown_type', defaultMessage: 'App response type not supported. Response type: {type}.', @@ -183,7 +184,7 @@ export function executeCommand(message: string, args: CommandArgs): ActionFuncAs )); } } catch (err: any) { - const message = err.message || intlShim.formatMessage({ + const message = err.message || intl.formatMessage({ id: 'apps.error.unknown', defaultMessage: 'Unknown error occurred.', }); diff --git a/webapp/channels/src/actions/invite_actions.ts b/webapp/channels/src/actions/invite_actions.ts index 669c74fdb3a..5d97852aea6 100644 --- a/webapp/channels/src/actions/invite_actions.ts +++ b/webapp/channels/src/actions/invite_actions.ts @@ -121,6 +121,7 @@ export function sendMembersInvites(teamId: string, users: UserProfile[], emails: email, reason: defineMessage({ id: 'admin.environment.smtp.smtpFailure', + // eslint-disable-next-line formatjs/enforce-placeholders -- a placeholder provided via messageWithLink when path is set defaultMessage: 'SMTP is not configured in System Console. Can be configured here.', }), path: ConsolePages.SMTP, @@ -229,6 +230,7 @@ export async function sendGuestInviteForUser( user, reason: defineMessage({ id: 'invite.guests.new-member', + // eslint-disable-next-line formatjs/enforce-placeholders -- count provided via values property, consumed by FormattedMessage in result_table defaultMessage: 'This guest has been added to the team and {count, plural, one {channel} other {channels}}.', values: { count: channels.length, @@ -301,6 +303,7 @@ export function sendGuestsInvites( email: res.email, reason: defineMessage({ id: 'admin.environment.smtp.smtpFailure', + // eslint-disable-next-line formatjs/enforce-placeholders -- a placeholder provided via messageWithLink when path is set defaultMessage: 'SMTP is not configured in System Console. Can be configured here.', }), path: ConsolePages.SMTP, @@ -445,6 +448,7 @@ export function sendMembersInvitesToChannels( email, reason: defineMessage({ id: 'admin.environment.smtp.smtpFailure', + // eslint-disable-next-line formatjs/enforce-placeholders -- a placeholder provided via messageWithLink when path is set defaultMessage: 'SMTP is not configured in System Console. Can be configured here.', }), path: ConsolePages.SMTP, diff --git a/webapp/channels/src/actions/marketplace.ts b/webapp/channels/src/actions/marketplace.ts index 234c6b8bc29..1ad62d948b3 100644 --- a/webapp/channels/src/actions/marketplace.ts +++ b/webapp/channels/src/actions/marketplace.ts @@ -12,11 +12,11 @@ import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {getFilter, getPlugin} from 'selectors/views/marketplace'; -import {intlShim} from 'components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies'; import type {DoAppCallResult} from 'components/suggestion/command_provider/app_command_parser/app_command_parser_dependencies'; import {createCallContext, createCallRequest} from 'utils/apps'; import {ActionTypes} from 'utils/constants'; +import {getIntl} from 'utils/i18n'; import type {ActionFuncAsync, ThunkActionFunc} from 'types/store'; @@ -125,6 +125,7 @@ export function installPlugin(id: string): ThunkActionFunc { // On success, it also requests the current state of the apps to reflect the newly installed app. export function installApp(id: string): ThunkActionFunc> { return async (dispatch, getState) => { + const intl = getIntl(); dispatch({ type: ActionTypes.INSTALLING_MARKETPLACE_ITEM, id, @@ -155,7 +156,7 @@ export function installApp(id: string): ThunkActionFunc> { const creq = createCallRequest(call, context, expand, values); - const res = await dispatch(doAppSubmit(creq, intlShim)) as DoAppCallResult; + const res = await dispatch(doAppSubmit(creq, intl)) as DoAppCallResult; if (res.error) { const errorResponse = res.error; diff --git a/webapp/channels/src/actions/notification_actions.tsx b/webapp/channels/src/actions/notification_actions.tsx index 9dd7d1a78a5..f883152c717 100644 --- a/webapp/channels/src/actions/notification_actions.tsx +++ b/webapp/channels/src/actions/notification_actions.tsx @@ -190,7 +190,7 @@ const getNotificationTitle = (channel: Pick, m } if (isCrtReply) { - title = Utils.localizeAndFormatMessage({id: 'notification.crt', defaultMessage: 'Reply in {title}'}, {title}); + title = Utils.localizeMessage({id: 'notification.crt', defaultMessage: 'Reply in {title}'}, {title}); } return title; diff --git a/webapp/channels/src/actions/status_actions.test.ts b/webapp/channels/src/actions/status_actions.test.ts index 20d50398b22..1e92076f751 100644 --- a/webapp/channels/src/actions/status_actions.test.ts +++ b/webapp/channels/src/actions/status_actions.test.ts @@ -132,31 +132,4 @@ describe('actions/status_actions', () => { expect(getStatusesByIds).not.toHaveBeenCalled(); }); }); - - describe('loadStatusesForProfilesMap', () => { - test('load statuses for users map', () => { - const state = cloneDeep(initialState); - const testStore = mockStore(state); - testStore.dispatch(Actions.loadStatusesForProfilesMap({ - user_id2: {id: 'user_id2', username: 'user2'} as UserProfile, - user_id3: {id: 'user_id3', username: 'user3'} as UserProfile, - user_id4: {id: 'user_id4', username: 'user4'} as UserProfile, - })); - expect(getStatusesByIds).toHaveBeenCalledWith((expect as GreatExpectations).arrayContainingExactly(['user_id2', 'user_id3', 'user_id4'])); - }); - - test('load statuses for empty users map', () => { - const state = cloneDeep(initialState); - const testStore = mockStore(state); - testStore.dispatch(Actions.loadStatusesForProfilesMap({})); - expect(getStatusesByIds).not.toHaveBeenCalled(); - }); - - test('load statuses for null users map', () => { - const state = cloneDeep(initialState); - const testStore = mockStore(state); - testStore.dispatch(Actions.loadStatusesForProfilesMap(null)); - expect(getStatusesByIds).not.toHaveBeenCalled(); - }); - }); }); diff --git a/webapp/channels/src/actions/status_actions.ts b/webapp/channels/src/actions/status_actions.ts index 834d05d5151..bbe0c29efdf 100644 --- a/webapp/channels/src/actions/status_actions.ts +++ b/webapp/channels/src/actions/status_actions.ts @@ -80,25 +80,6 @@ export function loadStatusesForProfilesList(users: UserProfile[] | null): Action }; } -export function loadStatusesForProfilesMap(users: Record | UserProfile[] | null): ActionFunc { - return (dispatch) => { - if (users == null) { - return {data: false}; - } - - const statusesToLoad = []; - for (const userId in users) { - if (Object.hasOwn(users, userId)) { - statusesToLoad.push(userId); - } - } - - dispatch(loadStatusesByIds(statusesToLoad)); - - return {data: true}; - }; -} - export function loadStatusesByIds(userIds: string[]): ActionFunc { return (dispatch, getState) => { const state = getState(); diff --git a/webapp/channels/src/actions/user_actions.ts b/webapp/channels/src/actions/user_actions.ts index 1af48cf4b66..82973852d5c 100644 --- a/webapp/channels/src/actions/user_actions.ts +++ b/webapp/channels/src/actions/user_actions.ts @@ -26,7 +26,7 @@ import type {ActionResult} from 'mattermost-redux/types/actions'; import {calculateUnreadCount} from 'mattermost-redux/utils/channel_utils'; import {loadCustomEmojisForCustomStatusesByUserIds} from 'actions/emoji_actions'; -import {loadStatusesForProfilesList, loadStatusesForProfilesMap} from 'actions/status_actions'; +import {loadStatusesForProfilesList} from 'actions/status_actions'; import {getDisplayedChannels} from 'selectors/views/channel_sidebar'; import store from 'stores/redux_store'; @@ -153,16 +153,6 @@ export function loadTeamMembersForProfilesList(profiles: UserProfile[], teamId: }; } -export function loadProfilesWithoutTeam(page: number, perPage: number, options?: Record): ActionFuncAsync { - return async (doDispatch) => { - const {data} = await doDispatch(UserActions.getProfilesWithoutTeam(page, perPage, options)); - - doDispatch(loadStatusesForProfilesMap(data!)); - - return {data}; - }; -} - export function loadTeamMembersAndChannelMembersForProfilesList(profiles: UserProfile[], teamId: string, channelId: string): ActionFuncAsync { return async (doDispatch, doGetState) => { const state = doGetState(); diff --git a/webapp/channels/src/actions/views/add_channel_dropdown.ts b/webapp/channels/src/actions/views/add_channel_dropdown.ts deleted file mode 100644 index b49cf3bd200..00000000000 --- a/webapp/channels/src/actions/views/add_channel_dropdown.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {ActionTypes} from 'utils/constants'; - -export function setAddChannelCtaDropdown(open: boolean) { - return { - type: ActionTypes.ADD_CHANNEL_CTA_DROPDOWN_TOGGLE, - open, - }; -} diff --git a/webapp/channels/src/actions/websocket_actions.jsx b/webapp/channels/src/actions/websocket_actions.jsx index 9be7be4c2cc..1d8f868c1cd 100644 --- a/webapp/channels/src/actions/websocket_actions.jsx +++ b/webapp/channels/src/actions/websocket_actions.jsx @@ -51,6 +51,7 @@ import { receivedNewPost, receivedPost, } from 'mattermost-redux/actions/posts'; +import {getRecap} from 'mattermost-redux/actions/recaps'; import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles'; import {fetchTeamScheduledPosts} from 'mattermost-redux/actions/scheduled_posts'; import {batchFetchStatusesProfilesGroupsFromPosts} from 'mattermost-redux/actions/status_profile_polling'; @@ -664,6 +665,9 @@ export function handleEvent(msg) { case SocketEvents.CONTENT_FLAGGING_REPORT_VALUE_CHANGED: dispatch(handleContentFlaggingReportValueChanged(msg)); break; + case SocketEvents.RECAP_UPDATED: + dispatch(handleRecapUpdated(msg)); + break; default: } @@ -2001,3 +2005,12 @@ export function handleContentFlaggingReportValueChanged(msg) { data: msg.data, }; } + +export function handleRecapUpdated(msg) { + const recapId = msg.data.recap_id; + + return async (doDispatch) => { + // Fetch the updated recap and dispatch to Redux + doDispatch(getRecap(recapId)); + }; +} diff --git a/webapp/channels/src/components/CLAUDE.OPTIONAL.md b/webapp/channels/src/components/CLAUDE.OPTIONAL.md new file mode 100644 index 00000000000..ce4d4b9f6a8 --- /dev/null +++ b/webapp/channels/src/components/CLAUDE.OPTIONAL.md @@ -0,0 +1,85 @@ +# CLAUDE: `components/` + +## Purpose +- Folder-by-feature organization for every UI surface. +- Each subfolder should include component, SCSS, tests, and local helpers when needed. + +## File Structure + +Each component directory should contain: +``` +my_component/ +├── index.ts # Re-exports +├── my_component.tsx # Component implementation +├── my_component.scss # Co-located styles (imported in component) +└── my_component.test.tsx +``` + +## Authoring Pattern +- **Functional Components**: Use hooks (`useSelector`, `useDispatch`, `useCallback`, `useMemo`). See `webapp/STYLE_GUIDE.md → React Component Structure`. +- **Small Files**: Split heavy logic into hooks (`useX.ts`) or child components. +- **Memoization**: Use `React.memo` for components with heavy render logic. +- **Code Splitting**: Lazy-load bulky routes using `makeAsyncComponent`: + +```typescript +const HeavyComponent = makeAsyncComponent( + () => import('./heavy_component'), +); +``` + +## Styling & Theming + +- **Co-location**: Put styles in SCSS file next to the component (`import './my_component.scss'`). +- **Root Class**: Match component name in PascalCase (e.g., `.MyComponent`). +- **Child Elements**: Use BEM-style suffix (e.g., `.MyComponent__title`). +- **Theme Variables**: Always use `var(--variable-name)` for colors from `sass/base/_css_variables.scss`. +- **No !important**: Use proper specificity and naming. +- **Transparency**: Use `rgba(var(--color-rgb), 0.5)` for opacity. + +```scss +// my_component.scss +.MyComponent { + color: var(--center-channel-color); + + &__title { + font-weight: 600; + } + + &.compact { + padding: 4px; + } +} +``` + +## Accessibility + +- **Semantic HTML**: Use ` + + `; exports[`components/SaveButton should match snapshot, on defaultMessage 1`] = ` - + + `; exports[`components/SaveButton should match snapshot, on defaultMessage 2`] = ` - + + `; exports[`components/SaveButton should match snapshot, on savingMessage 1`] = ` - + + `; exports[`components/SaveButton should match snapshot, on savingMessage 2`] = ` - + + `; diff --git a/webapp/channels/src/components/__snapshots__/spinner_button.test.tsx.snap b/webapp/channels/src/components/__snapshots__/spinner_button.test.tsx.snap index 9f7c5a1166c..62459aef46e 100644 --- a/webapp/channels/src/components/__snapshots__/spinner_button.test.tsx.snap +++ b/webapp/channels/src/components/__snapshots__/spinner_button.test.tsx.snap @@ -1,41 +1,40 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`components/SpinnerButton should match snapshot with children 1`] = ` - + + `; exports[`components/SpinnerButton should match snapshot with required props 1`] = ` - +
+
`; exports[`components/SpinnerButton should match snapshot with spinning 1`] = ` - +
+ +
`; diff --git a/webapp/channels/src/components/about_build_modal/about_build_modal.tsx b/webapp/channels/src/components/about_build_modal/about_build_modal.tsx index e5082e774e4..bcdf4440379 100644 --- a/webapp/channels/src/components/about_build_modal/about_build_modal.tsx +++ b/webapp/channels/src/components/about_build_modal/about_build_modal.tsx @@ -136,16 +136,20 @@ export default function AboutBuildModal(props: Props) { learnMore = (
+ {'mattermost.com'} + + ), + }} /> - - {'mattermost.com'} -
); licensee = ( @@ -162,14 +166,18 @@ export default function AboutBuildModal(props: Props) {
+ {'mattermost.com'} + + ), + }} /> - - {'mattermost.com'} -
); } diff --git a/webapp/channels/src/components/access_problem/index.tsx b/webapp/channels/src/components/access_problem/index.tsx index bcb6f1c8a3c..05429c7fbe0 100644 --- a/webapp/channels/src/components/access_problem/index.tsx +++ b/webapp/channels/src/components/access_problem/index.tsx @@ -34,12 +34,15 @@ const AccessProblem = ({ return (
- +
- {formatMessage({id: 'login.contact_admin.title'})} + {formatMessage({id: 'login.contact_admin.title', defaultMessage: 'Contact your workspace admin'})}
- {formatMessage({id: 'login.contact_admin.detail'})} + {formatMessage({id: 'login.contact_admin.detail', defaultMessage: "To access your team's workspace, contact your workspace admin. If you've been invited already, check your email inbox for a Mattermost workspace invite."})}
); diff --git a/webapp/channels/src/components/actions_menu/actions_menu_tutorial_tip.tsx b/webapp/channels/src/components/actions_menu/actions_menu_tutorial_tip.tsx index c112024f1d8..47a6af7510b 100644 --- a/webapp/channels/src/components/actions_menu/actions_menu_tutorial_tip.tsx +++ b/webapp/channels/src/components/actions_menu/actions_menu_tutorial_tip.tsx @@ -24,7 +24,7 @@ const title = ( const screen = ( ); const nextBtn = ( diff --git a/webapp/channels/src/components/add_users_to_group_modal/add_users_to_group_modal.tsx b/webapp/channels/src/components/add_users_to_group_modal/add_users_to_group_modal.tsx index e3b1c34802a..bbf17ea818a 100644 --- a/webapp/channels/src/components/add_users_to_group_modal/add_users_to_group_modal.tsx +++ b/webapp/channels/src/components/add_users_to_group_modal/add_users_to_group_modal.tsx @@ -118,6 +118,7 @@ const AddUsersToGroupModal = (props: Props) => { > diff --git a/webapp/channels/src/components/add_users_to_team_modal/__snapshots__/add_users_to_team_modal.test.tsx.snap b/webapp/channels/src/components/add_users_to_team_modal/__snapshots__/add_users_to_team_modal.test.tsx.snap index f5102a89c97..3ce013d2b4c 100644 --- a/webapp/channels/src/components/add_users_to_team_modal/__snapshots__/add_users_to_team_modal.test.tsx.snap +++ b/webapp/channels/src/components/add_users_to_team_modal/__snapshots__/add_users_to_team_modal.test.tsx.snap @@ -221,7 +221,7 @@ exports[`components/admin_console/add_users_to_team_modal/AddUsersToTeamModal sh perPage={50} placeholderText={ Object { - "defaultMessage": "Search and add members", + "defaultMessage": "Search for people", "id": "multiselect.placeholder", } } @@ -463,7 +463,7 @@ exports[`components/admin_console/add_users_to_team_modal/AddUsersToTeamModal sh perPage={50} placeholderText={ Object { - "defaultMessage": "Search and add members", + "defaultMessage": "Search for people", "id": "multiselect.placeholder", } } diff --git a/webapp/channels/src/components/add_users_to_team_modal/add_users_to_team_modal.tsx b/webapp/channels/src/components/add_users_to_team_modal/add_users_to_team_modal.tsx index 52395437422..5152c12dc37 100644 --- a/webapp/channels/src/components/add_users_to_team_modal/add_users_to_team_modal.tsx +++ b/webapp/channels/src/components/add_users_to_team_modal/add_users_to_team_modal.tsx @@ -262,7 +262,7 @@ export class AddUsersToTeamModal extends React.PureComponent { buttonSubmitLoadingText={buttonSubmitLoadingText} saving={this.state.saving} loading={this.state.loading} - placeholderText={defineMessage({id: 'multiselect.placeholder', defaultMessage: 'Search and add members'})} + placeholderText={defineMessage({id: 'multiselect.placeholder', defaultMessage: 'Search for people'})} /> diff --git a/webapp/channels/src/components/admin_console/__snapshots__/database_settings.test.tsx.snap b/webapp/channels/src/components/admin_console/__snapshots__/database_settings.test.tsx.snap index a10fd55962c..a9800880415 100644 --- a/webapp/channels/src/components/admin_console/__snapshots__/database_settings.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/__snapshots__/database_settings.test.tsx.snap @@ -11,7 +11,7 @@ exports[`components/DatabaseSettings should match snapshot 1`] = ` > @@ -219,13 +219,8 @@ exports[`components/DatabaseSettings should match snapshot 1`] = ` disabled={false} helpText={ } id="minimumHashtagLength" diff --git a/webapp/channels/src/components/admin_console/__snapshots__/elasticsearch_settings.test.tsx.snap b/webapp/channels/src/components/admin_console/__snapshots__/elasticsearch_settings.test.tsx.snap index 51a48a1e0a8..5e8b8c6a84a 100644 --- a/webapp/channels/src/components/admin_console/__snapshots__/elasticsearch_settings.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/__snapshots__/elasticsearch_settings.test.tsx.snap @@ -335,6 +335,7 @@ exports[`components/ElasticSearchSettings should match snapshot, disabled 1`] = helpText={ } @@ -827,6 +828,7 @@ exports[`components/ElasticSearchSettings should match snapshot, enabled 1`] = ` helpText={ } diff --git a/webapp/channels/src/components/admin_console/__snapshots__/message_export_settings.test.tsx.snap b/webapp/channels/src/components/admin_console/__snapshots__/message_export_settings.test.tsx.snap index ba2d74dd9c7..8bc6cb6cd24 100644 --- a/webapp/channels/src/components/admin_console/__snapshots__/message_export_settings.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/__snapshots__/message_export_settings.test.tsx.snap @@ -108,7 +108,7 @@ exports[`components/MessageExportSettings should match snapshot, disabled, actia "value": "csv", }, Object { - "text": "GlobalRelay EML", + "text": "Global Relay EML", "value": "globalrelay", }, ] @@ -270,7 +270,7 @@ exports[`components/MessageExportSettings should match snapshot, disabled, globa "value": "csv", }, Object { - "text": "GlobalRelay EML", + "text": "Global Relay EML", "value": "globalrelay", }, ] @@ -546,7 +546,7 @@ exports[`components/MessageExportSettings should match snapshot, enabled, actian "value": "csv", }, Object { - "text": "GlobalRelay EML", + "text": "Global Relay EML", "value": "globalrelay", }, ] @@ -708,7 +708,7 @@ exports[`components/MessageExportSettings should match snapshot, enabled, global "value": "csv", }, Object { - "text": "GlobalRelay EML", + "text": "Global Relay EML", "value": "globalrelay", }, ] diff --git a/webapp/channels/src/components/admin_console/__snapshots__/push_settings.test.tsx.snap b/webapp/channels/src/components/admin_console/__snapshots__/push_settings.test.tsx.snap index 2faf80fd9c9..edec14c2fe9 100644 --- a/webapp/channels/src/components/admin_console/__snapshots__/push_settings.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/__snapshots__/push_settings.test.tsx.snap @@ -148,14 +148,14 @@ exports[`components/PushSettings should match snapshot, licensed 1`] = ` } id="maxNotificationsPerChannel" label={ } @@ -279,14 +279,14 @@ exports[`components/PushSettings should match snapshot, unlicensed 1`] = ` } id="maxNotificationsPerChannel" label={ } diff --git a/webapp/channels/src/components/admin_console/access_control/__mocks__/monaco-editor.ts b/webapp/channels/src/components/admin_console/access_control/__mocks__/monaco-editor.ts index d6a969c7b57..c97540259fb 100644 --- a/webapp/channels/src/components/admin_console/access_control/__mocks__/monaco-editor.ts +++ b/webapp/channels/src/components/admin_console/access_control/__mocks__/monaco-editor.ts @@ -3,7 +3,16 @@ import {jest} from '@jest/globals'; -const monacoMock = { +const monacoMock: { + editor: { + create: jest.Mock; + defineTheme: jest.Mock; + setTheme: jest.Mock; + }; + languages: { + registerCompletionItemProvider: jest.Mock; + }; +} = { editor: { create: jest.fn(), defineTheme: jest.fn(), diff --git a/webapp/channels/src/components/admin_console/access_control/__snapshots__/policies.test.tsx.snap b/webapp/channels/src/components/admin_console/access_control/__snapshots__/policies.test.tsx.snap index b944eece25b..95b58bfd30a 100644 --- a/webapp/channels/src/components/admin_console/access_control/__snapshots__/policies.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/access_control/__snapshots__/policies.test.tsx.snap @@ -70,7 +70,6 @@ exports[`components/admin_console/access_control/PolicyList should match snapsho loading={false} nextPage={[Function]} onSearch={[Function]} - page={0} placeholderEmpty={ { const createChannelRow = (channel: Channel) => { const ariaLabel = `${channel.display_name}, ${channel.purpose}`.toLowerCase(); - let channelTypeIcon; - if (isArchivedChannel(channel)) { - channelTypeIcon = ; - } else if (isPrivateChannel(channel)) { - channelTypeIcon = ; - } else { - channelTypeIcon = ; - } + const ChannelIcon = getChannelIconComponent(channel); + const channelTypeIcon = ; const team = props.teams[channel.team_id]; @@ -173,7 +164,7 @@ const SearchableSyncJobChannelList = (props: Props) => { ); @@ -254,7 +245,7 @@ const SearchableSyncJobChannelList = (props: Props) => { clearable={true} onClear={handleClear} value={channelSearchValue} - aria-label={props.intl.formatMessage({id: 'filtered_channels_list.search', defaultMessage: 'Search Channels'})} + aria-label={props.intl.formatMessage({id: 'filtered_channels_list.search', defaultMessage: 'Search channels'})} /> ); @@ -306,7 +297,7 @@ const messages = defineMessages({ }, noMore: { id: 'more_channels.noMore', - defaultMessage: 'No results for {text}', + defaultMessage: 'No results for "{text}"', }, }); diff --git a/webapp/channels/src/components/admin_console/access_control/policies.test.tsx b/webapp/channels/src/components/admin_console/access_control/policies.test.tsx index 07de6f8d465..2e921f8ad6e 100644 --- a/webapp/channels/src/components/admin_console/access_control/policies.test.tsx +++ b/webapp/channels/src/components/admin_console/access_control/policies.test.tsx @@ -91,7 +91,6 @@ describe('components/admin_console/access_control/PolicyList', () => { wrapper.update(); expect(mockSearchPolicies).not.toHaveBeenCalled(); - expect(wrapper.find('DataGrid').prop('page')).toBe(0); }); test('should get columns correctly', () => { diff --git a/webapp/channels/src/components/admin_console/access_control/policies.tsx b/webapp/channels/src/components/admin_console/access_control/policies.tsx index d9cf981d1e1..11e66faea6a 100644 --- a/webapp/channels/src/components/admin_console/access_control/policies.tsx +++ b/webapp/channels/src/components/admin_console/access_control/policies.tsx @@ -350,7 +350,6 @@ export default function PolicyList(props: Props): JSX.Element { term={search} placeholderEmpty={placeholderEmpty} rowsContainerStyles={rowsContainerStyles} - page={page} nextPage={nextPage} previousPage={previousPage} /> diff --git a/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/channel_list.tsx b/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/channel_list.tsx index 044c8cae2e6..4742f21450a 100644 --- a/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/channel_list.tsx +++ b/webapp/channels/src/components/admin_console/access_control/policy_details/channel_list/channel_list.tsx @@ -15,12 +15,9 @@ import DataGrid from 'components/admin_console/data_grid/data_grid'; import type {Column, Row} from 'components/admin_console/data_grid/data_grid'; import type {FilterOptions} from 'components/admin_console/filter/filter'; import TeamFilterDropdown from 'components/admin_console/filter/team_filter_dropdown'; -import ArchiveIcon from 'components/widgets/icons/archive_icon'; -import GlobeIcon from 'components/widgets/icons/globe_icon'; -import LockIcon from 'components/widgets/icons/lock_icon'; import WithTooltip from 'components/with_tooltip'; -import {isArchivedChannel} from 'utils/channel_utils'; +import {getChannelIconComponent} from 'utils/channel_utils'; import {Constants} from 'utils/constants'; import './channel_list.scss'; @@ -421,19 +418,13 @@ class ChannelList extends React.PureComponent { ].slice(startCount - 1, endCount); return channelsToDisplay.map((channel) => { - // Determine which icon to display based on channel type - let iconToDisplay = ; - if (channel.type === Constants.PRIVATE_CHANNEL) { - iconToDisplay = ; - } - if (isArchivedChannel(channel)) { - iconToDisplay = ( - - ); - } + const ChannelIconComponent = getChannelIconComponent(channel); + const iconToDisplay = ( + + ); // Determine the button text and action based on the channel state const buttonText = ( @@ -554,7 +545,6 @@ class ChannelList extends React.PureComponent { columns={columns} rows={rows} loading={this.state.loading} - page={this.state.page} nextPage={this.nextPage} previousPage={this.previousPage} startCount={startCount} diff --git a/webapp/channels/src/components/admin_console/admin_console.tsx b/webapp/channels/src/components/admin_console/admin_console.tsx index 68ac8146b88..5bddad4f7b6 100644 --- a/webapp/channels/src/components/admin_console/admin_console.tsx +++ b/webapp/channels/src/components/admin_console/admin_console.tsx @@ -22,8 +22,6 @@ import DiscardChangesModal from 'components/discard_changes_modal'; import ModalController from 'components/modal_controller'; import SystemNotice from 'components/system_notice'; -import {applyTheme, resetTheme} from 'utils/utils'; - import {LhsItemType} from 'types/store/lhs'; import AdminSidebar from './admin_sidebar'; @@ -96,12 +94,10 @@ const AdminConsole = (props: Props) => { props.actions.selectTeam(''); document.body.classList.add('console__body'); document.getElementById('root')?.classList.add('console__root'); - resetTheme(); return () => { document.body.classList.remove('console__body'); document.getElementById('root')?.classList.remove('console__root'); - applyTheme(props.currentTheme); // Reset the admin console users management table properties props.actions.setAdminConsoleUsersManagementTableProperties(); diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index 15e554cb555..1690dc7c9ce 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -680,12 +680,12 @@ const AdminDefinition: AdminDefinitionType = { type: 'bool', key: 'AccessControlSettings.EnableAttributeBasedAccessControl', label: defineMessage({id: 'admin.accesscontrol.enableTitle', defaultMessage: 'Allow attribute based access controls on this server'}), - help_text: defineMessage({id: 'admin.accesscontrol.enableDesc', defaultMessage: 'Allow access restrictions based on user attributes using custom access policies. To effectively use this feature, you must define user attributes in the {userAttributes} section.'}), + help_text: defineMessage({id: 'admin.accesscontrol.enableDesc', defaultMessage: 'Allow access restrictions based on user attributes using custom access policies. To effectively use this feature, you must define user attributes in the {userAttributes} section.'}), // eslint-disable-line formatjs/enforce-placeholders -- userAttributes provided via help_text_values help_text_values: { userAttributes: ( @@ -736,7 +736,7 @@ const AdminDefinition: AdminDefinitionType = { ), schema: { id: 'AttributeBasedAccessControl', - name: defineMessage({id: 'admin.accesscontrol.title', defaultMessage: 'Attribute-Based Access (Beta)'}), + name: defineMessage({id: 'admin.accesscontrol.title', defaultMessage: 'Attribute-Based Access'}), settings: [ { type: 'custom', @@ -773,14 +773,14 @@ const AdminDefinition: AdminDefinitionType = { settings: [ { type: 'banner', - label: defineMessage({id: 'admin.rate.noteDescription', defaultMessage: 'Changing properties in this section will require a server restart before taking effect.'}), + label: defineMessage({id: 'admin.info_banner.restart_required.desc', defaultMessage: 'Changing properties in this section will require a server restart before taking effect.'}), banner_type: 'info', }, { type: 'text', key: 'ServiceSettings.SiteURL', label: defineMessage({id: 'admin.service.siteURL', defaultMessage: 'Site URL:'}), - help_text: defineMessage({id: 'admin.service.siteURLDescription', defaultMessage: 'The URL that users will use to access Mattermost. Standard ports, such as 80 and 443, can be omitted, but non-standard ports are required. For example: http://example.com:8065. This setting is required. Mattermost may be hosted at a subpath. For example: http://example.com:8065/company/mattermost. A restart is required before the server will work correctly.'}), + help_text: defineMessage({id: 'admin.service.siteURLDescription', defaultMessage: 'The URL that users will use to access Mattermost. Standard ports, such as 80 and 443, can be omitted, but non-standard ports are required. For example: http://example.com:8065. This setting is required.\n \nMattermost may be hosted at a subpath. For example: http://example.com:8065/company/mattermost. A restart is required before the server will work correctly.'}), // eslint-disable-line formatjs/no-multiple-whitespaces help_text_markdown: true, placeholder: defineMessage({id: 'admin.service.siteURLExample', defaultMessage: 'E.g.: "http://example.com:8065"'}), isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.WEB_SERVER)), @@ -791,7 +791,7 @@ const AdminDefinition: AdminDefinitionType = { action: testSiteURL, label: defineMessage({id: 'admin.service.testSiteURL', defaultMessage: 'Test Live URL'}), loading: defineMessage({id: 'admin.service.testSiteURLTesting', defaultMessage: 'Testing...'}), - error_message: defineMessage({id: 'admin.service.testSiteURLFail', defaultMessage: 'Test unsuccessful: {error}'}), + error_message: defineMessage({id: 'admin.service.testSiteURLFail', defaultMessage: 'Test unsuccessful: {error}'}), // eslint-disable-line formatjs/enforce-placeholders -- error provided at runtime success_message: defineMessage({id: 'admin.service.testSiteURLSuccess', defaultMessage: 'Test successful. This is a valid URL.'}), isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.WEB_SERVER)), }, @@ -808,7 +808,7 @@ const AdminDefinition: AdminDefinitionType = { key: 'ServiceSettings.Forward80To443', label: defineMessage({id: 'admin.service.forward80To443', defaultMessage: 'Forward port 80 to 443:'}), help_text: defineMessage({id: 'admin.service.forward80To443Description', defaultMessage: 'Forwards all insecure traffic from port 80 to secure port 443. Not recommended when using a proxy server.'}), - disabled_help_text: defineMessage({id: 'admin.service.forward80To443Description.disabled', defaultMessage: 'Forwards all insecure traffic from port 80 to secure port 443. Not recommended when using a proxy server. This setting cannot be enabled until your server is [listening](#ServiceSettings.ListenAddress) on port 443.'}), + disabled_help_text: defineMessage({id: 'admin.service.forward80To443Description.disabled', defaultMessage: 'Forwards all insecure traffic from port 80 to secure port 443. Not recommended when using a proxy server.\n \nThis setting cannot be enabled until your server is [listening](#ServiceSettings.ListenAddress) on port 443.'}), // eslint-disable-line formatjs/no-multiple-whitespaces disabled_help_text_markdown: true, isDisabled: it.any( it.cloudLicensed, @@ -858,7 +858,7 @@ const AdminDefinition: AdminDefinitionType = { key: 'ServiceSettings.UseLetsEncrypt', label: defineMessage({id: 'admin.service.useLetsEncrypt', defaultMessage: 'Use Let\'s Encrypt:'}), help_text: defineMessage({id: 'admin.service.useLetsEncryptDescription', defaultMessage: 'Enable the automatic retrieval of certificates from Let\'s Encrypt. The certificate will be retrieved when a client attempts to connect from a new domain. This will work with multiple domains.'}), - disabled_help_text: defineMessage({id: 'admin.service.useLetsEncryptDescription.disabled', defaultMessage: "Enable the automatic retrieval of certificates from Let's Encrypt. The certificate will be retrieved when a client attempts to connect from a new domain. This will work with multiple domains. This setting cannot be enabled unless the [Forward port 80 to 443](#ServiceSettings.Forward80To443) setting is set to true."}), + disabled_help_text: defineMessage({id: 'admin.service.useLetsEncryptDescription.disabled', defaultMessage: "Enable the automatic retrieval of certificates from Let's Encrypt. The certificate will be retrieved when a client attempts to connect from a new domain. This will work with multiple domains.\n \nThis setting cannot be enabled unless the [Forward port 80 to 443](#ServiceSettings.Forward80To443) setting is set to true."}), // eslint-disable-line formatjs/no-multiple-whitespaces disabled_help_text_markdown: true, isDisabled: it.any( it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.WEB_SERVER)), @@ -928,7 +928,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'text', key: 'ServiceSettings.ManagedResourcePaths', label: defineMessage({id: 'admin.service.managedResourcePaths', defaultMessage: 'Managed Resource Paths:'}), - help_text: defineMessage({id: 'admin.service.managedResourcePathsDescription', defaultMessage: 'A comma-separated list of paths on the Mattermost server that are managed by another service. See here for more information.'}), + help_text: defineMessage({id: 'admin.service.managedResourcePathsDescription', defaultMessage: 'A comma-separated list of paths on the Mattermost server that are managed by another service. See here for more information.'}), // eslint-disable-line formatjs/enforce-placeholders -- link provided via help_text_values help_text_markdown: false, help_text_values: { link: (msg: string) => ( @@ -947,7 +947,7 @@ const AdminDefinition: AdminDefinitionType = { action: reloadConfig, key: 'ReloadConfigButton', label: defineMessage({id: 'admin.reload.button', defaultMessage: 'Reload Configuration From Disk'}), - help_text: defineMessage({id: 'admin.reload.reloadDescription', defaultMessage: 'Deployments using multiple databases can switch from one master database to another without restarting the Mattermost server by updating "config.json" to the new desired configuration and using the {featureName} feature to load the new settings while the server is running. The administrator should then use the {recycleDatabaseConnections} feature to recycle the database connections based on the new settings.'}), + help_text: defineMessage({id: 'admin.reload.reloadDescription', defaultMessage: 'Deployments using multiple databases can switch from one master database to another without restarting the Mattermost server by updating "config.json" to the new desired configuration and using the {featureName} feature to load the new settings while the server is running. The administrator should then use the {recycleDatabaseConnections} feature to recycle the database connections based on the new settings.'}), // eslint-disable-line formatjs/enforce-placeholders -- featureName, recycleDatabaseConnections provided via help_text_values help_text_values: { featureName: ( @@ -968,7 +968,7 @@ const AdminDefinition: AdminDefinitionType = { ), }, - error_message: defineMessage({id: 'admin.reload.reloadFail', defaultMessage: 'Reload unsuccessful: {error}'}), + error_message: defineMessage({id: 'admin.reload.reloadFail', defaultMessage: 'Reload unsuccessful: {error}'}), // eslint-disable-line formatjs/enforce-placeholders -- error provided at runtime isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.WEB_SERVER)), }, { @@ -977,7 +977,7 @@ const AdminDefinition: AdminDefinitionType = { action: invalidateAllCaches, label: defineMessage({id: 'admin.purge.button', defaultMessage: 'Purge All Caches'}), help_text: defineMessage({id: 'admin.purge.purgeDescription', defaultMessage: 'This will purge all the in-memory caches for things like sessions, accounts, channels, etc. Deployments using High Availability will attempt to purge all the servers in the cluster. Purging the caches may adversely impact performance.'}), - error_message: defineMessage({id: 'admin.purge.purgeFail', defaultMessage: 'Purging unsuccessful: {error}'}), + error_message: defineMessage({id: 'admin.purge.purgeFail', defaultMessage: 'Purging unsuccessful: {error}'}), // eslint-disable-line formatjs/enforce-placeholders -- error provided at runtime isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.WEB_SERVER)), }, ], @@ -1028,7 +1028,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'dropdown', key: 'FileSettings.DriverName', label: defineMessage({id: 'admin.image.storeTitle', defaultMessage: 'File Storage System:'}), - help_text: defineMessage({id: 'admin.image.storeDescription', defaultMessage: 'Storage system where files and image attachments are saved. Selecting "Amazon S3" enables fields to enter your Amazon credentials and bucket details. Selecting "Local File System" enables the field to specify a local file directory.'}), + help_text: defineMessage({id: 'admin.image.storeDescription', defaultMessage: 'Storage system where files and image attachments are saved.\n \nSelecting "Amazon S3" enables fields to enter your Amazon credentials and bucket details.\n \nSelecting "Local File System" enables the field to specify a local file directory.'}), // eslint-disable-line formatjs/no-multiple-whitespaces help_text_markdown: true, options: [ { @@ -1067,7 +1067,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'bool', key: 'FileSettings.ExtractContent', label: defineMessage({id: 'admin.image.extractContentTitle', defaultMessage: 'Enable document search by content:'}), - help_text: defineMessage({id: 'admin.image.extractContentDescription', defaultMessage: 'When enabled, supported document types are searchable by their content. Search results for existing documents may be incomplete until a data migration is executed.'}), + help_text: defineMessage({id: 'admin.image.extractContentDescription', defaultMessage: 'When enabled, supported document types are searchable by their content. Search results for existing documents may be incomplete until a data migration is executed.'}), // eslint-disable-line formatjs/enforce-placeholders -- link provided via help_text_values help_text_markdown: false, help_text_values: { link: (msg: string) => ( @@ -1087,7 +1087,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'bool', key: 'FileSettings.ArchiveRecursion', label: defineMessage({id: 'admin.image.archiveRecursionTitle', defaultMessage: 'Enable searching content of documents within ZIP files:'}), - help_text: defineMessage({id: 'admin.image.archiveRecursionDescription', defaultMessage: 'When enabled, content of documents within ZIP files will be returned in search results. This may have an impact on server performance for large files. '}), + help_text: defineMessage({id: 'admin.image.archiveRecursionDescription', defaultMessage: 'When enabled, content of documents within ZIP files will be returned in search results. This may have an impact on server performance for large files.'}), isDisabled: it.any( it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)), it.configIsFalse('FileSettings', 'ExtractContent'), @@ -1109,7 +1109,7 @@ const AdminDefinition: AdminDefinitionType = { key: 'FileSettings.AmazonS3PathPrefix', label: defineMessage({id: 'admin.image.amazonS3PathPrefixTitle', defaultMessage: 'Amazon S3 Path Prefix:'}), help_text: defineMessage({id: 'admin.image.amazonS3PathPrefixDescription', defaultMessage: 'Prefix you selected for your S3 bucket in AWS.'}), - placeholder: defineMessage({id: 'admin.image.amazonS3PathPrefixExample', defaultMessage: 'E.g.: "subdir1/" or you can leave it .'}), + placeholder: defineMessage({id: 'admin.image.amazonS3PathPrefixExample', defaultMessage: 'E.g.: "subdir1" or you can leave it empty.'}), isDisabled: it.any( it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.ENVIRONMENT.FILE_STORAGE)), it.not(it.stateEquals('FileSettings.DriverName', FILE_STORAGE_DRIVER_S3)), @@ -1130,7 +1130,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'text', key: 'FileSettings.AmazonS3AccessKeyId', label: defineMessage({id: 'admin.image.amazonS3IdTitle', defaultMessage: 'Amazon S3 Access Key ID:'}), - help_text: defineMessage({id: 'admin.image.amazonS3IdDescription', defaultMessage: '(Optional) Only required if you do not want to authenticate to S3 using an IAM role. Enter the Access Key ID provided by your Amazon EC2 administrator.'}), + help_text: defineMessage({id: 'admin.image.amazonS3IdDescription', defaultMessage: '(Optional) Only required if you do not want to authenticate to S3 using an IAM role. Enter the Access Key ID provided by your Amazon EC2 administrator.'}), // eslint-disable-line formatjs/enforce-placeholders -- link provided via help_text_values help_text_values: { link: (msg: string) => ( documentation to learn more.'}), + help_text: defineMessage({id: 'admin.image.amazonS3SSEDescription', defaultMessage: 'When true, encrypt files in Amazon S3 using server-side encryption with Amazon S3-managed keys. See documentation to learn more.'}), // eslint-disable-line formatjs/enforce-placeholders -- link provided via help_text_values help_text_values: { link: (msg: string) => ( IAM role. Enter the Access Key ID provided by your Amazon EC2 administrator.'}), + help_text: defineMessage({id: 'admin.image.amazonS3IdDescription', defaultMessage: '(Optional) Only required if you do not want to authenticate to S3 using an IAM role. Enter the Access Key ID provided by your Amazon EC2 administrator.'}), // eslint-disable-line formatjs/enforce-placeholders -- link provided via help_text_values help_text_values: { link: (msg: string) => ( documentation to learn more.'}), + help_text: defineMessage({id: 'admin.image.amazonS3SSEDescription', defaultMessage: 'When true, encrypt files in Amazon S3 using server-side encryption with Amazon S3-managed keys. See documentation to learn more.'}), // eslint-disable-line formatjs/enforce-placeholders -- link provided via help_text_values help_text_values: { link: (msg: string) => ( documentation to learn more.'}), + help_text: defineMessage({id: 'admin.image.proxyTypeDescription', defaultMessage: 'Configure an image proxy to load all Markdown images through a proxy. The image proxy prevents users from making insecure image requests, provides caching for increased performance, and automates image adjustments such as resizing. See documentation to learn more.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( @@ -1925,7 +1925,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'bool', key: 'LogSettings.EnableDiagnostics', label: defineMessage({id: 'admin.log.enableDiagnostics', defaultMessage: 'Enable Diagnostics and Error Reporting:'}), - help_text: defineMessage({id: 'admin.log.enableDiagnosticsDescription', defaultMessage: 'Enable this feature to improve the quality and performance of Mattermost by sending error reporting and diagnostic information to Mattermost, Inc. Read our privacy policy to learn more.'}), + help_text: defineMessage({id: 'admin.log.enableDiagnosticsDescription', defaultMessage: 'Enable this feature to improve the quality and performance of Mattermost by sending error reporting and diagnostic information to Mattermost, Inc. Read our privacy policy to learn more.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_markdown: false, help_text_values: { link: (msg: string) => ( @@ -1946,7 +1946,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'longtext', key: 'LogSettings.AdvancedLoggingJSON', label: defineMessage({id: 'admin.log.AdvancedLoggingJSONTitle', defaultMessage: 'Advanced Logging:'}), - help_text: defineMessage({id: 'admin.log.AdvancedLoggingJSONDescription', defaultMessage: 'The JSON configuration for Advanced Logging. Please see documentation to learn more about Advanced Logging and the JSON format it uses.'}), + help_text: defineMessage({id: 'admin.log.AdvancedLoggingJSONDescription', defaultMessage: 'The JSON configuration for Advanced Logging. Please see documentation to learn more about Advanced Logging and the JSON format it uses.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_markdown: false, help_text_values: { link: (msg: string) => ( @@ -2014,7 +2014,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'bool', key: 'MetricsSettings.Enable', label: defineMessage({id: 'admin.metrics.enableTitle', defaultMessage: 'Enable Performance Monitoring:'}), - help_text: defineMessage({id: 'admin.metrics.enableDescription', defaultMessage: 'When true, Mattermost will enable performance monitoring collection and profiling. Please see documentation to learn more about configuring performance monitoring for Mattermost.'}), + help_text: defineMessage({id: 'admin.metrics.enableDescription', defaultMessage: 'When true, Mattermost will enable performance monitoring collection and profiling. Please see documentation to learn more about configuring performance monitoring for Mattermost.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_markdown: false, help_text_values: { link: (msg: string) => ( @@ -2032,7 +2032,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'bool', key: 'MetricsSettings.EnableClientMetrics', label: defineMessage({id: 'admin.metrics.enableClientMetricsTitle', defaultMessage: 'Enable Client Performance Monitoring:'}), - help_text: defineMessage({id: 'admin.metrics.enableClientMetricsDescription', defaultMessage: 'When true, Mattermost will enable performance monitoring collection for web and desktop app users. Please see documentation to learn more about configuring performance monitoring for Mattermost.'}), + help_text: defineMessage({id: 'admin.metrics.enableClientMetricsDescription', defaultMessage: 'When true, Mattermost will enable performance monitoring collection for web and desktop app users. Please see documentation to learn more about configuring performance monitoring for Mattermost.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_markdown: false, help_text_values: { link: (msg: string) => ( @@ -2107,7 +2107,7 @@ const AdminDefinition: AdminDefinitionType = { key: 'ServiceSettings.AllowedUntrustedInternalConnections', label: defineMessage({id: 'admin.service.internalConnectionsTitle', defaultMessage: 'Allow untrusted internal connections to: '}), placeholder: defineMessage({id: 'admin.service.internalConnectionsEx', defaultMessage: 'webhooks.internal.example.com 127.0.0.1 10.0.16.0/28'}), - help_text: defineMessage({id: 'admin.service.internalConnectionsDesc', defaultMessage: 'A whitelist of local network addresses that can be requested by the Mattermost server on behalf of a client. Care should be used when configuring this setting to prevent unintended access to your local network. See documentation to learn more. Changing this requires a server restart before taking effect.'}), + help_text: defineMessage({id: 'admin.service.internalConnectionsDesc', defaultMessage: 'A whitelist of local network addresses that can be requested by the Mattermost server on behalf of a client. Care should be used when configuring this setting to prevent unintended access to your local network. See documentation to learn more. Changing this requires a server restart before taking effect.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( @@ -2374,7 +2374,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'text', key: 'SupportSettings.TermsOfServiceLink', label: defineMessage({id: 'admin.support.termsTitle', defaultMessage: 'Terms of Use Link:'}), - help_text: defineMessage({id: 'admin.support.termsDesc', defaultMessage: 'Link to the terms under which users may use your online service. By default, this includes the "Mattermost Conditions of Use (End Users)" explaining the terms under which Mattermost software is provided to end users. If you change the default link to add your own terms for using the service you provide, your new terms must include a link to the default terms so end users are aware of the Mattermost Conditions of Use (End User) for Mattermost software.'}), + help_text: defineMessage({id: 'admin.support.termsDesc', defaultMessage: 'Link to the terms under which users may use your online service. By default, this includes the "Mattermost Acceptable Use Policy" explaining the terms under which Mattermost software is provided to end users. If you change the default link to add your own terms for using the service you provide, your new terms must include a link to the default terms so end users are aware of the Mattermost Acceptable Use Policy for Mattermost software.'}), isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.CUSTOMIZATION)), isHidden: it.configIsTrue('ExperimentalSettings', 'RestrictSystemAdmin'), }, @@ -2603,7 +2603,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'dropdown', key: 'TeamSettings.RestrictDirectMessage', label: defineMessage({id: 'admin.team.restrictDirectMessage', defaultMessage: 'Enable users to open Direct Message channels with:'}), - help_text: defineMessage({id: 'admin.team.restrictDirectMessageDesc', defaultMessage: '"Any user on the Mattermost server" enables users to open a Direct Message channel with any user on the server, even if they are not on any teams together. "Any member of the team" limits the ability in the Direct Messages "More" menu to only open Direct Message channels with users who are in the same team.'}), + help_text: defineMessage({id: 'admin.team.restrictDirectMessageDesc', defaultMessage: "'Any user on the Mattermost server' enables users to open a Direct Message channel with any user on the server, even if they are not on any teams together. 'Any member of the team' limits the ability in the Direct Messages 'More' menu to only open Direct Message channels with users who are in the same team."}), options: [ { value: 'any', @@ -2641,7 +2641,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'bool', key: 'TeamSettings.LockTeammateNameDisplay', label: defineMessage({id: 'admin.lockTeammateNameDisplay', defaultMessage: 'Lock Teammate Name Display for all users: '}), - help_text: defineMessage({id: 'admin.lockTeammateNameDisplayHelpText', defaultMessage: 'When true, disables users\' ability to change settings under Account Menu > Account Settings > Display > Teammate Name Display.'}), + help_text: defineMessage({id: 'admin.lockTeammateNameDisplayHelpText', defaultMessage: "When true, disables users' ability to change settings under Settings > Display > Teammate Name Display."}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { strong: (msg: string) => {msg}, }, @@ -2756,7 +2756,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'dropdown', key: 'EmailSettings.EmailNotificationContentsType', label: defineMessage({id: 'admin.environment.notifications.contents.label', defaultMessage: 'Email Notification Contents:'}), - help_text: defineMessage({id: 'admin.environment.notifications.contents.help', defaultMessage: '**Send full message contents** - Sender name and channel are included in email notifications. **Send generic description with only sender name** - Only the name of the person who sent the message, with no information about channel name or message contents are included in email notifications. Typically used for compliance reasons if Mattermost contains confidential information and policy dictates it cannot be stored in email.'}), + help_text: defineMessage({id: 'admin.environment.notifications.contents.help', defaultMessage: '**Send full message contents** - Sender name and channel are included in email notifications.\n **Send generic description with only sender name** - Only the name of the person who sent the message, with no information about channel name or message contents are included in email notifications. Typically used for compliance reasons if Mattermost contains confidential information and policy dictates it cannot be stored in email.'}), // eslint-disable-line formatjs/no-multiple-whitespaces help_text_markdown: true, options: [ { @@ -2831,7 +2831,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'dropdown', key: 'EmailSettings.PushNotificationContents', label: defineMessage({id: 'admin.environment.notifications.pushContents.label', defaultMessage: 'Push Notification Contents:'}), - help_text: defineMessage({id: 'admin.environment.notifications.pushContents.help', defaultMessage: "**Generic description with only sender name** - Includes only the name of the person who sent the message in push notifications, with no information about channel name or message contents. **Generic description with sender and channel names** - Includes the name of the person who sent the message and the channel it was sent in, but not the message contents. **Full message content sent in the notification payload** - Includes the message contents in the push notification payload that is relayed through Apple's Push Notification Service (APNS) or Google's Firebase Cloud Messaging (FCM). It is **highly recommended** this option only be used with an \"https\" protocol to encrypt the connection and protect confidential information sent in messages."}), + help_text: defineMessage({id: 'admin.environment.notifications.pushContents.help', defaultMessage: '**Generic description with only sender name** - Includes only the name of the person who sent the message in push notifications, with no information about channel name or message contents.\n **Generic description with sender and channel names** - Includes the name of the person who sent the message and the channel it was sent in, but not the message contents.\n **Full message content sent in the notification payload** - Includes the message contents in the push notification payload that is relayed through Apple\'s Push Notification Service (APNS) or Google\'s Firebase Cloud Messaging (FCM). It is **highly recommended** this option only be used with an "https" protocol to encrypt the connection and protect confidential information sent in messages.'}), // eslint-disable-line formatjs/no-multiple-whitespaces help_text_markdown: true, options: [ { @@ -2854,7 +2854,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'dropdown', key: 'EmailSettings.PushNotificationContents', label: defineMessage({id: 'admin.environment.notifications.pushContents.label', defaultMessage: 'Push Notification Contents:'}), - help_text: defineMessage({id: 'admin.environment.notifications.pushContents.withIdLoaded.help', defaultMessage: "**Generic description with only sender name** - Includes only the name of the person who sent the message in push notifications, with no information about channel name or message contents. **Generic description with sender and channel names** - Includes the name of the person who sent the message and the channel it was sent in, but not the message contents. **Full message content sent in the notification payload** - Includes the message contents in the push notification payload that is relayed through Apple's Push Notification Service (APNS) or Google's Firebase Cloud Messaging (FCM). It is **highly recommended** this option only be used with an \"https\" protocol to encrypt the connection and protect confidential information sent in messages. **Full message content fetched from the server on receipt** - The notification payload relayed through APNS or FCM contains no message content, instead it contains a unique message ID used to fetch message content from the server when a push notification is received by a device. If the server cannot be reached, a generic notification will be displayed."}), + help_text: defineMessage({id: 'admin.environment.notifications.pushContents.withIdLoaded.help', defaultMessage: "**Generic description with only sender name** - Includes only the name of the person who sent the message in push notifications, with no information about channel name or message contents.\n **Generic description with sender and channel names** - Includes the name of the person who sent the message and the channel it was sent in, but not the message contents.\n **Full message content sent in the notification payload** - Includes the message contents in the push notification payload that is relayed through Apple's Push Notification Service (APNS) or Google's Firebase Cloud Messaging (FCM). It is **highly recommended** this option only be used with an \"https\" protocol to encrypt the connection and protect confidential information sent in messages.\n**Full message content fetched from the server on receipt** - The notification payload relayed through APNS or FCM contains no message content, instead it contains a unique message ID used to fetch message content from the server when a push notification is received by a device. If the server cannot be reached, a generic notification will be displayed."}), // eslint-disable-line formatjs/no-multiple-whitespaces help_text_markdown: true, options: [ { @@ -3022,7 +3022,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'dropdown', key: 'ServiceSettings.CollapsedThreads', label: defineMessage({id: 'admin.experimental.collapsedThreads.title', defaultMessage: 'Threaded Discussions'}), - help_text: defineMessage({id: 'admin.experimental.collapsedThreads.desc', defaultMessage: 'When enabled (default off), users must enable Threaded Discussions in Settings. When disabled, users cannot access Threaded Discussions. Please review our documentation for known issues and help provide feedback in our Community Channel.'}), + help_text: defineMessage({id: 'admin.experimental.collapsedThreads.desc', defaultMessage: 'When enabled (default off), users have the option to enable Threaded Discussions in Account Settings. When enabled (default on), users see Threaded Discussions by default and have the option to disable it in Account Settings. When always on, users are required to use Threaded Discussions and cannot disable it.'}), help_text_values: { linkKnownIssues: (msg: string) => ( documentation.'}), + help_text: defineMessage({id: 'admin.posts.postPriority.desc', defaultMessage: 'When enabled, users can configure a visual indicator to communicate messages that are important or urgent. Learn more about message priority in our documentation.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( documentation.'}), + help_text: defineMessage({id: 'admin.posts.persistentNotifications.desc', defaultMessage: 'When enabled, users can trigger repeating notifications for the recipients of urgent messages. Learn more about message priority and persistent notifications in our documentation.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( documentation.'}), + help_text: defineMessage({id: 'admin.posts.persistentNotificationsMaxRecipients.desc', defaultMessage: 'Configure the maximum number of recipients to which users may send persistent notifications. Learn more about message priority and persistent notifications in our documentation.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( documentation.'}), + help_text: defineMessage({id: 'admin.posts.persistentNotificationsInterval.desc', defaultMessage: 'Configure the number of minutes between repeated notifications for urgent messages send with persistent notifications. Learn more about message priority and persistent notifications in our documentation.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( documentation.'}), + help_text: defineMessage({id: 'admin.posts.persistentNotificationsMaxCount.desc', defaultMessage: 'Configure the maximum number of times users may receive persistent notifications. Learn more about message priority and persistent notifications in our documentation.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( documentation.'}), + help_text: defineMessage({id: 'admin.posts.persistentNotificationsGuests.desc', defaultMessage: 'Whether a guest is able to require persistent notifications. Learn more about message priority and persistent notifications in our documentation.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( documentation for details.'}), + help_text: defineMessage({id: 'admin.customization.enablePermalinkPreviewsDesc', defaultMessage: 'When enabled, links to Mattermost messages will generate a preview for any users that have access to the original message. Please review our documentation for details.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( documentation for details about text formatting.'}), + help_text: defineMessage({id: 'admin.customization.enableInlineLatexDesc', defaultMessage: 'Enable rendering of inline Latex code. If false, Latex can only be rendered in a code block using syntax highlighting. Please review our documentation for details about text formatting.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( Google Developers Tutorial for instructions on how to obtain a key and add YouTube Data API v3 as a service to your key.'}), + help_text: defineMessage({id: 'admin.service.googleDescription', defaultMessage: 'Set this key to enable the display of titles for embedded YouTube video previews. Without the key, YouTube previews will still be created based on hyperlinks appearing in messages or comments but they will not show the video title. View a Google Developers Tutorial for instructions on how to obtain a key and add YouTube Data API v3 as a service to your key.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( Learn more about notices in our documentation.'}), + help_text: defineMessage({id: 'admin.notices.enableAdminNoticesDescription', defaultMessage: 'When enabled, System Admins will receive notices about available server upgrades and relevant system administration features. Learn more about notices in our documentation.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( Learn more about notices in our documentation.'}), + help_text: defineMessage({id: 'admin.notices.enableEndUserNoticesDescription', defaultMessage: 'When enabled, all users will receive notices about available client upgrades and relevant end user features to improve user experience. Learn more about notices in our documentation.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( Multi-factor authentication is available for accounts with AD/LDAP or email login. If other login methods are used, MFA should be configured with the authentication provider.'}), + label: defineMessage({id: 'admin.mfa.bannerDesc', defaultMessage: 'Multi-factor authentication is available for accounts with AD/LDAP or email login. If other login methods are used, MFA should be configured with the authentication provider.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided label_values: { link: (msg: string) => ( multi-factor authentication is required for login. New users will be required to configure MFA on signup. Logged in users without MFA configured are redirected to the MFA setup page until configuration is complete.\n \nIf your system has users with login methods other than AD/LDAP and email, MFA must be enforced with the authentication provider outside of Mattermost.'}), + help_text: defineMessage({id: 'admin.service.enforceMfaDesc', defaultMessage: 'When true, multi-factor authentication is required for login. New users will be required to configure MFA on signup. Logged in users without MFA configured are redirected to the MFA setup page until configuration is complete.\n \nIf your system has users with login methods other than AD/LDAP and email, MFA must be enforced with the authentication provider outside of Mattermost.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_markdown: false, help_text_values: { link: (msg: string) => ( @@ -3943,7 +3931,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'bool', key: 'SamlSettings.Enable', label: defineMessage({id: 'admin.saml.enableTitle', defaultMessage: 'Enable Login With SAML 2.0:'}), - help_text: defineMessage({id: 'admin.saml.enableDescription', defaultMessage: 'When true, Mattermost allows login using SAML 2.0. Please see documentation to learn more about configuring SAML for Mattermost.'}), + help_text: defineMessage({id: 'admin.saml.enableDescription', defaultMessage: 'When true, Mattermost allows login using SAML 2.0. Please see documentation to learn more about configuring SAML for Mattermost.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_markdown: false, help_text_values: { link: (msg: string) => ( @@ -3961,7 +3949,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'bool', key: 'SamlSettings.EnableSyncWithLdap', label: defineMessage({id: 'admin.saml.enableSyncWithLdapTitle', defaultMessage: 'Enable Synchronizing SAML Accounts With AD/LDAP:'}), - help_text: defineMessage({id: 'admin.saml.enableSyncWithLdapDescription', defaultMessage: 'When true, Mattermost periodically synchronizes SAML user attributes, including user deactivation and removal, from AD/LDAP. Enable and configure synchronization settings at Authentication > AD/LDAP. When false, user attributes are updated from SAML during user login. See documentation to learn more.'}), + help_text: defineMessage({id: 'admin.saml.enableSyncWithLdapDescription', defaultMessage: 'When true, Mattermost periodically synchronizes SAML user attributes, including user deactivation and removal, from AD/LDAP. Enable and configure synchronization settings at Authentication > AD/LDAP. When false, user attributes are updated from SAML during user login. See documentation to learn more.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( Note: SAML IDs must match the LDAP IDs to prevent disabling of user accounts. Please review documentation for more information.'}), + help_text: defineMessage({id: 'admin.saml.enableSyncWithLdapIncludeAuthDescription', defaultMessage: 'When true, Mattermost will override the SAML ID attribute with the AD/LDAP ID attribute if configured or override the SAML Email attribute with the AD/LDAP Email attribute if SAML ID attribute is not present. This will allow you automatically migrate users from Email binding to ID binding to prevent creation of new users when an email address changes for a user. Moving from true to false, will remove the override from happening. Note: SAML IDs must match the LDAP IDs to prevent disabling of user accounts. Please review documentation for more information.'}), // eslint-disable-line formatjs/no-multiple-whitespaces, formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( your-mattermost-url"'}), + placeholder: defineMessage({id: 'admin.saml.assertionConsumerServiceURLEx', defaultMessage: 'E.g.: "your-mattermost-url"'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided placeholder_values: { urlChunk: (chunk: string) => `https://'<${chunk}>'/login/sso/saml`, }, @@ -4121,7 +4109,7 @@ const AdminDefinition: AdminDefinitionType = { key: 'SamlSettings.ServiceProviderIdentifier', label: defineMessage({id: 'admin.saml.serviceProviderIdentifierTitle', defaultMessage: 'Service Provider Identifier:'}), help_text: defineMessage({id: 'admin.saml.serviceProviderIdentifierDesc', defaultMessage: 'The unique identifier for the Service Provider, usually the same as Service Provider Login URL. In ADFS, this MUST match the Relying Party Identifier.'}), - placeholder: defineMessage({id: 'admin.saml.serviceProviderIdentifierEx', defaultMessage: 'E.g.: "your-mattermost-url"'}), + placeholder: defineMessage({id: 'admin.saml.serviceProviderIdentifierEx', defaultMessage: 'E.g.: "your-mattermost-url"'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided placeholder_values: { urlChunk: (chunk: string) => `https://'<${chunk}>'/login/sso/saml`, }, @@ -4275,7 +4263,7 @@ const AdminDefinition: AdminDefinitionType = { key: 'SamlSettings.GuestAttribute', label: defineMessage({id: 'admin.saml.guestAttrTitle', defaultMessage: 'Guest Attribute:'}), placeholder: defineMessage({id: 'admin.saml.guestAttrEx', defaultMessage: 'E.g.: "usertype=Guest" or "isGuest=true"'}), - help_text: defineMessage({id: 'admin.saml.guestAttrDesc', defaultMessage: '(Optional) Requires Guest Access to be enabled before being applied. The attribute in the SAML Assertion that will be used to apply a guest role to users in Mattermost. 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 attribute is removed/changed from your guest user in SAML and the user is still active, they 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: defineMessage({id: 'admin.saml.guestAttrDesc', defaultMessage: '(Optional) Requires Guest Access to be enabled before being applied. The attribute in the SAML Assertion that will be used to apply a guest role to users in Mattermost. 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 attribute is removed/changed from your guest user in SAML and the user is still active, they will not be promoted to a member and will retain their Guest role. Guests can be promoted in **System Console > User Management**.\n \n \nExisting 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.'}), // eslint-disable-line formatjs/no-multiple-whitespaces help_text_markdown: true, isDisabled: it.any( it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.SAML)), @@ -4297,7 +4285,7 @@ const AdminDefinition: AdminDefinitionType = { key: 'SamlSettings.AdminAttribute', label: defineMessage({id: 'admin.saml.adminAttrTitle', defaultMessage: 'Admin Attribute:'}), placeholder: defineMessage({id: 'admin.saml.adminAttrEx', defaultMessage: 'E.g.: "usertype=Admin" or "isAdmin=true"'}), - help_text: defineMessage({id: 'admin.saml.adminAttrDesc', defaultMessage: '(Optional) The attribute in the SAML Assertion 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: defineMessage({id: 'admin.saml.adminAttrDesc', defaultMessage: '(Optional) The attribute in the SAML Assertion 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.\n \nExisting 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**.'}), // eslint-disable-line formatjs/no-multiple-whitespaces help_text_markdown: true, isDisabled: it.any( it.not(it.isSystemAdmin), @@ -4486,7 +4474,7 @@ const AdminDefinition: AdminDefinitionType = { { value: Constants.GITLAB_SERVICE, display_name: defineMessage({id: 'admin.oauth.gitlab', defaultMessage: 'GitLab'}), - help_text: defineMessage({id: 'admin.gitlab.EnableMarkdownDesc', defaultMessage: '1. Log in to your GitLab account and go to Profile Settings -> Applications.\n2. Enter Redirect URIs "your-mattermost-url" (example: http://localhost:8065/login/gitlab/complete) and "your-mattermost-url".\n3. Then use "Application Secret Key" and "Application ID" fields from GitLab to complete the options below.\n4. Complete the Endpoint URLs below.'}), + help_text: defineMessage({id: 'admin.gitlab.EnableMarkdownDesc', defaultMessage: '1. Log in to your GitLab account and go to Profile Settings -> Applications.\n2. Enter Redirect URIs "your-mattermost-url" (example: http://localhost:8065/login/gitlab/complete) and "your-mattermost-url".\n3. Then use "Application Secret Key" and "Application ID" fields from GitLab to complete the options below.\n4. Complete the Endpoint URLs below.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { loginUrlChunk: (chunk: string) => `<${chunk}>/login/gitlab/complete`, signupUrlChunk: (chunk: string) => `<${chunk}>/signup/gitlab/complete`, @@ -4497,7 +4485,7 @@ const AdminDefinition: AdminDefinitionType = { value: Constants.GOOGLE_SERVICE, display_name: defineMessage({id: 'admin.oauth.google', defaultMessage: 'Google Apps'}), isHidden: it.all(it.not(it.licensedForFeature('GoogleOAuth')), it.not(it.cloudLicensed)), - help_text: defineMessage({id: 'admin.google.EnableMarkdownDesc', defaultMessage: '1. Log in to your Google account.\n2. Go to https://console.developers.google.com, click Credentials in the left hand sidebar and enter "Mattermost - your-company-name" as the Project Name, then click Create.\n3. Click the OAuth consent screen header and enter "Mattermost" as the Product name shown to users, then click Save.\n4. Under the Credentials header, click Create credentials, choose OAuth client ID and select Web Application.\n5. Under Restrictions and Authorized redirect URIs enter "your-mattermost-url/signup/google/complete" (example: http://localhost:8065/signup/google/complete). Click Create.\n6. Paste the Client ID and Client Secret to the fields below, then click Save.\n7. Go to the Google People API and click Enable.'}), + help_text: defineMessage({id: 'admin.google.EnableMarkdownDesc', defaultMessage: '1. Log in to your Google account.\n2. Go to https://console.developers.google.com, click Credentials in the left hand side.\n 3. Under the Credentials header, click Create credentials, choose OAuth client ID and select Web Application.\n 4. Enter "Mattermost - your-company-name" as the Name.\n 5. Under Authorized redirect URIs enter "your-mattermost-url/signup/google/complete" (example: http://localhost:8065/signup/google/complete). Click Create.\n 6. Paste the Client ID and Client Secret to the fields below, then click Save.\n 7. Go to the Google People API and click Enable.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_markdown: false, help_text_values: { linkLogin: (msg: string) => ( @@ -4531,7 +4519,7 @@ const AdminDefinition: AdminDefinitionType = { value: Constants.OFFICE365_SERVICE, display_name: defineMessage({id: 'admin.oauth.office365', defaultMessage: 'Entra ID'}), isHidden: it.all(it.not(it.licensedForFeature('Office365OAuth')), it.not(it.cloudLicensed)), - help_text: defineMessage({id: 'admin.office365.EnableMarkdownDesc', defaultMessage: '1. Log in to your Microsoft account. \n2. In Microsoft, go to Applications and App Registrations in the left pane.\n3. Select New registration, then enter "Mattermost - your-company-name" as the Application Name. \n4. Under Redirect URI, select Web, and enter "your-mattermost-url/signup/office365/complete" as the Redirect URI. Select Register.\n5. Copy the Microsoft Application (client) ID value, and paste it below as the Client ID value. \n6. Copy the Microsoft Directory (tenant) ID value, and paste it below as the Directory (tenant) ID value. \n7. In Microsoft, create a new client secret. Copy the resulting client secret value, and paste it below as the Client Secret value. Select Save.'}), + help_text: defineMessage({id: 'admin.office365.EnableMarkdownDesc', defaultMessage: '1. Log in to your Microsoft account. \n2. In Microsoft, go to Applications and App Registrations in the left pane.\n3. Select New registration, then enter "Mattermost - your-company-name" as the Application Name. \n4. Under Redirect URI, select Web, and enter "your-mattermost-url/signup/office365/complete" as the Redirect URI. Select Register.\n5. Copy the Microsoft Application (client) ID value, and paste it below as the Client ID value. \n6. Copy the Microsoft Directory (tenant) ID value, and paste it below as the Directory (tenant) ID value. \n7. In Microsoft, create a new client secret. Copy the resulting client secret value, and paste it below as the Client Secret value. Select Save.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_markdown: false, help_text_values: { linkLogin: (msg: string) => ( @@ -4825,7 +4813,7 @@ const AdminDefinition: AdminDefinitionType = { { value: Constants.GITLAB_SERVICE, display_name: defineMessage({id: 'admin.openid.gitlab', defaultMessage: 'GitLab'}), - help_text: defineMessage({id: 'admin.gitlab.EnableMarkdownDesc', defaultMessage: '1. Log in to your GitLab account and go to Profile Settings -> Applications.\n2. Enter Redirect URIs "your-mattermost-url" (example: http://localhost:8065/login/gitlab/complete) and "your-mattermost-url".\n3. Then use "Application Secret Key" and "Application ID" fields from GitLab to complete the options below.\n4. Complete the Endpoint URLs below.'}), + help_text: defineMessage({id: 'admin.gitlab.EnableMarkdownDesc', defaultMessage: '1. Log in to your GitLab account and go to Profile Settings -> Applications.\n2. Enter Redirect URIs "your-mattermost-url" (example: http://localhost:8065/login/gitlab/complete) and "your-mattermost-url".\n3. Then use "Application Secret Key" and "Application ID" fields from GitLab to complete the options below.\n4. Complete the Endpoint URLs below.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { loginUrlChunk: (chunk: string) => `<${chunk}>/login/gitlab/complete`, signupUrlChunk: (chunk: string) => `<${chunk}>/signup/gitlab/complete`, @@ -4835,7 +4823,7 @@ const AdminDefinition: AdminDefinitionType = { { value: Constants.GOOGLE_SERVICE, display_name: defineMessage({id: 'admin.openid.google', defaultMessage: 'Google Apps'}), - help_text: defineMessage({id: 'admin.google.EnableMarkdownDesc', defaultMessage: '1. Log in to your Google account.\n2. Go to https://console.developers.google.com], click Credentials in the left hand side.\n 3. Under the Credentials header, click Create credentials, choose OAuth client ID and select Web Application.\n 4. Enter "Mattermost - your-company-name" as the Name.\n 5. Under Authorized redirect URIs enter "your-mattermost-url/signup/google/complete" (example: http://localhost:8065/signup/google/complete). Click Create.\n 6. Paste the Client ID and Client Secret to the fields below, then click Save.\n 7. Go to the Google People API and click Enable.'}), + help_text: defineMessage({id: 'admin.google.EnableMarkdownDesc', defaultMessage: '1. Log in to your Google account.\n2. Go to https://console.developers.google.com, click Credentials in the left hand side.\n 3. Under the Credentials header, click Create credentials, choose OAuth client ID and select Web Application.\n 4. Enter "Mattermost - your-company-name" as the Name.\n 5. Under Authorized redirect URIs enter "your-mattermost-url/signup/google/complete" (example: http://localhost:8065/signup/google/complete). Click Create.\n 6. Paste the Client ID and Client Secret to the fields below, then click Save.\n 7. Go to the Google People API and click Enable.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_markdown: false, help_text_values: { linkLogin: (msg: string) => ( @@ -4868,7 +4856,7 @@ const AdminDefinition: AdminDefinitionType = { { value: Constants.OFFICE365_SERVICE, display_name: defineMessage({id: 'admin.openid.office365', defaultMessage: 'Entra ID'}), - help_text: defineMessage({id: 'admin.office365.EnableMarkdownDesc', defaultMessage: '1. Log in to your Microsoft account. \n2. In Microsoft, go to Applications and App Registrations in the left pane.\n3. Select New registration, then enter "Mattermost - your-company-name" as the Application Name. \n4. Under Redirect URI, select Web, and enter "your-mattermost-url/signup/office365/complete" as the Redirect URI. Select Register.\n5. Copy the Microsoft Application (client) ID value, and paste it below as the Client ID value. \n6. Copy the Microsoft Directory (tenant) ID value, and paste it below as the Directory (tenant) ID value. \n7. In Microsoft, create a new client secret. Copy the resulting client secret value, and paste it below as the Client Secret value. Select Save.'}), + help_text: defineMessage({id: 'admin.office365.EnableMarkdownDesc', defaultMessage: '1. Log in to your Microsoft account. \n2. In Microsoft, go to Applications and App Registrations in the left pane.\n3. Select New registration, then enter "Mattermost - your-company-name" as the Application Name. \n4. Under Redirect URI, select Web, and enter "your-mattermost-url/signup/office365/complete" as the Redirect URI. Select Register.\n5. Copy the Microsoft Application (client) ID value, and paste it below as the Client ID value. \n6. Copy the Microsoft Directory (tenant) ID value, and paste it below as the Directory (tenant) ID value. \n7. In Microsoft, create a new client secret. Copy the resulting client secret value, and paste it below as the Client Secret value. Select Save.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_markdown: false, help_text_values: { linkLogin: (msg: string) => ( @@ -4935,7 +4923,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'text', key: 'GitLabSettings.Id', label: defineMessage({id: 'admin.openid.clientIdTitle', defaultMessage: 'Client ID:'}), - help_text: defineMessage({id: 'admin.openid.clientIdDescription', defaultMessage: 'Obtaining the Client ID differs across providers. Please check you provider\'s documentation'}), + help_text: defineMessage({id: 'admin.openid.clientIdDescription', defaultMessage: 'Obtaining the Client ID differs across providers. Please check you provider\'s documentation.'}), placeholder: defineMessage({id: 'admin.gitlab.clientIdExample', defaultMessage: 'E.g.: "jcuS8PuvcpGhpgHhlcpT1Mx42pnqMxQY"'}), isHidden: it.not(it.stateEquals('openidType', Constants.GITLAB_SERVICE)), isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.OPENID)), @@ -4944,8 +4932,8 @@ const AdminDefinition: AdminDefinitionType = { type: 'text', key: 'GitLabSettings.Secret', label: defineMessage({id: 'admin.openid.clientSecretTitle', defaultMessage: 'Client Secret:'}), - help_text: defineMessage({id: 'admin.openid.clientSecretDescription', defaultMessage: 'Obtaining the Client Secret differs across providers. Please check you provider\'s documentation'}), - placeholder: defineMessage({id: 'admin.gitlab.clientSecretExample', defaultMessage: 'E.g.: "jcuS8PuvcpGhpgHhlcpT1Mx442pnqMxQY"'}), + help_text: defineMessage({id: 'admin.openid.clientSecretDescription', defaultMessage: 'Obtaining the Client Secret differs across providers. Please check you provider\'s documentation.'}), + placeholder: defineMessage({id: 'admin.gitlab.clientSecretExample', defaultMessage: 'E.g.: "jcuS8PuvcpGhpgHhlcpT1Mx42pnqMxQY"'}), isHidden: it.not(it.stateEquals('openidType', Constants.GITLAB_SERVICE)), isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.OPENID)), }, @@ -4963,7 +4951,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'text', key: 'GoogleSettings.Id', label: defineMessage({id: 'admin.openid.clientIdTitle', defaultMessage: 'Client ID:'}), - help_text: defineMessage({id: 'admin.openid.clientIdDescription', defaultMessage: 'Obtaining the Client ID differs across providers. Please check you provider\'s documentation'}), + help_text: defineMessage({id: 'admin.openid.clientIdDescription', defaultMessage: 'Obtaining the Client ID differs across providers. Please check you provider\'s documentation.'}), placeholder: defineMessage({id: 'admin.google.clientIdExample', defaultMessage: 'E.g.: "7602141235235-url0fhs1mayfasbmop5qlfns8dh4.apps.googleusercontent.com"'}), isHidden: it.not(it.stateEquals('openidType', Constants.GOOGLE_SERVICE)), isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.OPENID)), @@ -4972,7 +4960,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'text', key: 'GoogleSettings.Secret', label: defineMessage({id: 'admin.openid.clientSecretTitle', defaultMessage: 'Client Secret:'}), - help_text: defineMessage({id: 'admin.openid.clientSecretDescription', defaultMessage: 'Obtaining the Client Secret differs across providers. Please check you provider\'s documentation'}), + help_text: defineMessage({id: 'admin.openid.clientSecretDescription', defaultMessage: 'Obtaining the Client Secret differs across providers. Please check you provider\'s documentation.'}), placeholder: defineMessage({id: 'admin.google.clientSecretExample', defaultMessage: 'E.g.: "H8sz0Az-dDs2p15-7QzD231"'}), isHidden: it.not(it.stateEquals('openidType', Constants.GOOGLE_SERVICE)), isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.OPENID)), @@ -5005,7 +4993,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'text', key: 'Office365Settings.Id', label: defineMessage({id: 'admin.openid.clientIdTitle', defaultMessage: 'Client ID:'}), - help_text: defineMessage({id: 'admin.openid.clientIdDescription', defaultMessage: 'Obtaining the Client ID differs across providers. Please check you provider\'s documentation'}), + help_text: defineMessage({id: 'admin.openid.clientIdDescription', defaultMessage: 'Obtaining the Client ID differs across providers. Please check you provider\'s documentation.'}), placeholder: defineMessage({id: 'admin.office365.clientIdExample', defaultMessage: 'E.g.: "adf3sfa2-ag3f-sn4n-ids0-sh1hdax192qq"'}), isHidden: it.not(it.stateEquals('openidType', Constants.OFFICE365_SERVICE)), isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.OPENID)), @@ -5014,7 +5002,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'text', key: 'Office365Settings.Secret', label: defineMessage({id: 'admin.openid.clientSecretTitle', defaultMessage: 'Client Secret:'}), - help_text: defineMessage({id: 'admin.openid.clientSecretDescription', defaultMessage: 'Obtaining the Client Secret differs across providers. Please check you provider\'s documentation'}), + help_text: defineMessage({id: 'admin.openid.clientSecretDescription', defaultMessage: 'Obtaining the Client Secret differs across providers. Please check you provider\'s documentation.'}), placeholder: defineMessage({id: 'admin.office365.clientSecretExample', defaultMessage: 'E.g.: "shAieM47sNBfgl20f8ci294"'}), isHidden: it.not(it.stateEquals('openidType', Constants.OFFICE365_SERVICE)), isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.OPENID)), @@ -5052,7 +5040,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'text', key: 'OpenIdSettings.Id', label: defineMessage({id: 'admin.openid.clientIdTitle', defaultMessage: 'Client ID:'}), - help_text: defineMessage({id: 'admin.openid.clientIdDescription', defaultMessage: 'Obtaining the Client ID differs across providers. Please check you provider\'s documentation'}), + help_text: defineMessage({id: 'admin.openid.clientIdDescription', defaultMessage: 'Obtaining the Client ID differs across providers. Please check you provider\'s documentation.'}), placeholder: defineMessage({id: 'admin.openid.clientIdExample', defaultMessage: 'E.g.: "adf3sfa2-ag3f-sn4n-ids0-sh1hdax192qq"'}), isHidden: it.any(it.not(it.stateEquals('openidType', Constants.OPENID_SERVICE)), it.licensedForCloudStarter), isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.OPENID)), @@ -5061,7 +5049,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'text', key: 'OpenIdSettings.Secret', label: defineMessage({id: 'admin.openid.clientSecretTitle', defaultMessage: 'Client Secret:'}), - help_text: defineMessage({id: 'admin.openid.clientSecretDescription', defaultMessage: 'Obtaining the Client Secret differs across providers. Please check you provider\'s documentation'}), + help_text: defineMessage({id: 'admin.openid.clientSecretDescription', defaultMessage: 'Obtaining the Client Secret differs across providers. Please check you provider\'s documentation.'}), placeholder: defineMessage({id: 'admin.openid.clientSecretExample', defaultMessage: 'E.g.: "H8sz0Az-dDs2p15-7QzD231"'}), isHidden: it.any(it.not(it.stateEquals('openidType', Constants.OPENID_SERVICE)), it.licensedForCloudStarter), isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.AUTHENTICATION.OPENID)), @@ -5179,7 +5167,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'bool', key: 'GuestAccountsSettings.EnforceMultifactorAuthentication', label: defineMessage({id: 'admin.guest_access.mfaTitle', defaultMessage: 'Enforce Multi-factor Authentication: '}), - help_text: defineMessage({id: 'admin.guest_access.mfaDescription', defaultMessage: 'When true, multi-factor authentication for guests is required for login. New guest users will be required to configure MFA on signup. Logged in guest users without MFA configured are redirected to the MFA setup page until configuration is complete.\n \nIf your system has guest users with login methods other than AD/LDAP and email, MFA must be enforced with the authentication provider outside of Mattermost.'}), + help_text: defineMessage({id: 'admin.guest_access.mfaDescription', defaultMessage: 'When true, multi-factor authentication for guests is required for login. New guest users will be required to configure MFA on signup. Logged in guest users without MFA configured are redirected to the MFA setup page until configuration is complete.\n \nIf your system has guest users with login methods other than AD/LDAP and email, MFA must be enforced with the authentication provider outside of Mattermost.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( documentation to learn more.'}), + help_text: defineMessage({id: 'admin.service.webhooksDescription', defaultMessage: 'When true, incoming webhooks will be allowed. To help combat phishing attacks, all posts from webhooks will be labelled by a BOT tag. See documentation to learn more.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( documentation to learn more.'}), + help_text: defineMessage({id: 'admin.service.outWebhooksDesc', defaultMessage: 'When true, outgoing webhooks will be allowed. See documentation to learn more.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( documentation to learn more.'}), + help_text: defineMessage({id: 'admin.service.outgoingOAuthConnectionsDesc', defaultMessage: 'When true, outgoing webhooks and slash commands will use set up oauth connections to authenticate with third party services. See documentation to learn more.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (text: string) => ( {text} @@ -5335,7 +5323,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'bool', key: 'ServiceSettings.EnableCommands', label: defineMessage({id: 'admin.service.cmdsTitle', defaultMessage: 'Enable Custom Slash Commands: '}), - help_text: defineMessage({id: 'admin.service.cmdsDesc', defaultMessage: 'When true, custom slash commands will be allowed. See documentation to learn more.'}), + help_text: defineMessage({id: 'admin.service.cmdsDesc', defaultMessage: 'When true, custom slash commands will be allowed. See documentation to learn more.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( documentation to learn more.'}), + help_text: defineMessage({id: 'admin.oauth.providerDescription', defaultMessage: 'When true, Mattermost can act as an OAuth 2.0 service provider allowing Mattermost to authorize API requests from external applications. See documentation to learn more.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( Slash Commands, Outgoing Webhooks, Interactive Messages and Interactive Dialogs.'}), + help_text: defineMessage({id: 'admin.service.integrationRequestDesc', defaultMessage: 'The number of seconds to wait for Integration requests. That includes Slash Commands, Outgoing Webhooks, Interactive Messages and Interactive Dialogs.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { slashCommands: (msg: string) => ( user access tokens for integrations in Account Menu > Account Settings > Security. They can be used to authenticate against the API and give full access to the account.\n\n To manage who can create personal access tokens or to search users by token ID, go to the User Management > Users page.'}), + label: defineMessage({id: 'admin.service.userAccessTokensTitle', defaultMessage: 'Enable Personal Access Tokens:'}), + help_text: defineMessage({id: 'admin.service.userAccessTokensDescription', defaultMessage: 'When true, users can create personal access tokens for integrations in Profile > Security. They can be used to authenticate against the API and give full access to the account.\n\n To manage who can create personal access tokens or to search users by token ID, go to System Console > User Management > Users.'}), // eslint-disable-line formatjs/no-multiple-whitespaces, formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( Integrations > Bot Accounts. Bot accounts are similar to user accounts except they cannot be used to log in. See documentation to learn more.'}), + help_text: defineMessage({id: 'admin.service.enableBotAccountCreation', defaultMessage: 'When true, System Admins can create bot accounts for integrations in Integrations > Bot Accounts. Bot accounts are similar to user accounts except they cannot be used to log in. See documentation to learn more.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_markdown: false, help_text_values: { siteURL: getSiteURL(), @@ -5499,7 +5487,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'bool', key: 'ServiceSettings.DisableBotsWhenOwnerIsDeactivated', label: defineMessage({id: 'admin.service.disableBotOwnerDeactivatedTitle', defaultMessage: 'Disable bot accounts when owner is deactivated:'}), - help_text: defineMessage({id: 'admin.service.disableBotWhenOwnerIsDeactivated', defaultMessage: 'When a user is deactivated, disables all bot accounts managed by the user. To re-enable bot accounts, go to [Integrations > Bot Accounts]({siteURL}/_redirect/integrations/bots).'}), + help_text: defineMessage({id: 'admin.service.disableBotWhenOwnerIsDeactivated', defaultMessage: 'When a user is deactivated, disables all bot accounts managed by the user. To re-enable bot accounts, go to [Integrations > Bot Accounts]({siteURL}/_redirect/integrations/bots).'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_markdown: true, help_text_values: {siteURL: getSiteURL()}, isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.INTEGRATIONS.BOT_ACCOUNTS)), @@ -5732,7 +5720,7 @@ const AdminDefinition: AdminDefinitionType = { settings: [ { type: 'banner', - label: defineMessage({id: 'admin.compliance.newComplianceExportBanner', defaultMessage: 'This feature is replaced by a new Compliance Export feature, and will be removed in a future release. We recommend migrating to the new system.'}), + label: defineMessage({id: 'admin.compliance.newComplianceExportBanner', defaultMessage: 'This feature is replaced by a new Compliance Export feature, and will be removed in a future release. We recommend migrating to the new system.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided label_values: { link: (msg: string) => ( @@ -5747,7 +5735,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'bool', key: 'ComplianceSettings.Enable', label: defineMessage({id: 'admin.compliance.enableTitle', defaultMessage: 'Enable Compliance Reporting:'}), - help_text: defineMessage({id: 'admin.compliance.enableDesc', defaultMessage: 'When true, Mattermost allows compliance reporting from the Compliance and Auditing tab. See documentation to learn more.'}), + help_text: defineMessage({id: 'admin.compliance.enableDesc', defaultMessage: 'When true, Mattermost allows compliance reporting from the Compliance and Auditing tab. See documentation to learn more.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( documentation to learn more about Advanced Logging and the JSON format it uses.'}), + help_text: defineMessage({id: 'admin.log.AdvancedAuditLoggingJSONDescription', defaultMessage: 'The JSON configuration for Advanced Audit Logging. Please see documentation to learn more about Advanced Logging and the JSON format it uses.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_markdown: false, help_text_values: { link: (msg: string) => ( @@ -6020,7 +6008,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'bool', key: 'ServiceSettings.ExperimentalEnableAuthenticationTransfer', label: defineMessage({id: 'admin.experimental.experimentalEnableAuthenticationTransfer.title', defaultMessage: 'Allow Authentication Transfer:'}), - help_text: defineMessage({id: 'admin.experimental.experimentalEnableAuthenticationTransfer.desc', defaultMessage: 'When true, users can change their sign-in method to any that is enabled on the server, any via Account Settings or the APIs. When false, Users cannot change their sign-in method, regardless of which authentication options are enabled.'}), + help_text: defineMessage({id: 'admin.experimental.experimentalEnableAuthenticationTransfer.desc', defaultMessage: 'When true, users can change their sign-in method to any that is enabled on the server, either via their Profile or the APIs. When false, Users cannot change their sign-in method, regardless of which authentication options are enabled.'}), help_text_markdown: false, isHidden: it.any( // documented as E20 and higher, but only E10 in the code it.not(it.licensed), @@ -6115,7 +6103,7 @@ const AdminDefinition: AdminDefinitionType = { type: 'bool', key: 'ServiceSettings.ExperimentalEnableHardenedMode', label: defineMessage({id: 'admin.experimental.experimentalEnableHardenedMode.title', defaultMessage: 'Enable Hardened Mode:'}), - help_text: defineMessage({id: 'admin.experimental.experimentalEnableHardenedMode.desc', defaultMessage: 'Enables a hardened mode for Mattermost that makes user experience trade-offs in the interest of security. See documentation to learn more.'}), + help_text: defineMessage({id: 'admin.experimental.experimentalEnableHardenedMode.desc', defaultMessage: 'Enables a hardened mode for Mattermost that makes user experience trade-offs in the interest of security. See documentation to learn more.'}), // eslint-disable-line formatjs/enforce-placeholders -- placeholders provided help_text_values: { link: (msg: string) => ( diff --git a/webapp/channels/src/components/admin_console/admin_definition_helpers.tsx b/webapp/channels/src/components/admin_console/admin_definition_helpers.tsx index 826a1cee38a..9617bcbc180 100644 --- a/webapp/channels/src/components/admin_console/admin_definition_helpers.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition_helpers.tsx @@ -113,6 +113,7 @@ export const getRestrictedIndicator = (displayBlocked = false, minimumPlanRequir minimumPlanRequiredForFeature={minimumPlanRequiredForFeature} tooltipMessageBlocked={defineMessage({ id: 'admin.sidebar.restricted_indicator.tooltip.message.blocked', + // eslint-disable-next-line formatjs/enforce-placeholders -- Placeholders provided in RestrictedIndicator defaultMessage: 'This is {article} {minimumPlanRequiredForFeature} feature, available with an upgrade or free {trialLength}-day trial', })} /> 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 index 42e70400331..dab19ceefdd 100644 --- a/webapp/channels/src/components/admin_console/admin_definition_ldap_wizard.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition_ldap_wizard.tsx @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable formatjs/enforce-placeholders -- Admin wizard uses help_text_values for placeholders, which ESLint cannot statically analyze */ + import React from 'react'; import {FormattedMessage, defineMessage} from 'react-intl'; diff --git a/webapp/channels/src/components/admin_console/admin_navbar_dropdown/admin_navbar_dropdown.tsx b/webapp/channels/src/components/admin_console/admin_navbar_dropdown/admin_navbar_dropdown.tsx index 70a2bd2c96f..1ef70d840cc 100644 --- a/webapp/channels/src/components/admin_console/admin_navbar_dropdown/admin_navbar_dropdown.tsx +++ b/webapp/channels/src/components/admin_console/admin_navbar_dropdown/admin_navbar_dropdown.tsx @@ -107,7 +107,7 @@ class AdminNavbarDropdown extends React.PureComponent { } @@ -2187,7 +2187,7 @@ exports[`components/AdminSidebar should match snapshot with license with enterpr name="experimental/feature_flags" title={ } @@ -3261,7 +3261,7 @@ exports[`components/AdminSidebar should match snapshot with license with enterpr name="experimental/feature_flags" title={ } @@ -4246,7 +4246,7 @@ exports[`components/AdminSidebar should match snapshot with license with profess name="experimental/feature_flags" title={ } @@ -5220,7 +5220,7 @@ exports[`components/AdminSidebar should match snapshot with workspace optimizati name="experimental/feature_flags" title={ } @@ -6233,7 +6233,7 @@ exports[`components/AdminSidebar should match snapshot, not prevent the console name="experimental/feature_flags" title={ } @@ -7207,7 +7207,7 @@ exports[`components/AdminSidebar should match snapshot, render plugins without a name="experimental/feature_flags" title={ } @@ -8082,7 +8082,7 @@ exports[`components/AdminSidebar should match snapshot, with license (with all f name="experimental/feature_flags" title={ } @@ -9067,7 +9067,7 @@ exports[`components/AdminSidebar should match snapshot, with license (without an name="experimental/feature_flags" title={ } diff --git a/webapp/channels/src/components/admin_console/audit_logging/index.tsx b/webapp/channels/src/components/admin_console/audit_logging/index.tsx index cbe44169c0b..fbd235920bc 100644 --- a/webapp/channels/src/components/admin_console/audit_logging/index.tsx +++ b/webapp/channels/src/components/admin_console/audit_logging/index.tsx @@ -3,7 +3,6 @@ import type {ComponentType} from 'react'; import React from 'react'; -import type {IntlShape} from 'react-intl'; import {useIntl} from 'react-intl'; import {removeAuditCertificate, uploadAuditCertificate} from 'actions/admin_actions'; @@ -16,9 +15,6 @@ import RemoveFileSetting from '../remove_file_setting'; type Props = { id?: string; - config: any; - license: any; - intl: IntlShape; value: any; onChange: (id: string, value: string) => void; disabled: boolean; diff --git a/webapp/channels/src/components/admin_console/audits/audits.tsx b/webapp/channels/src/components/admin_console/audits/audits.tsx index 2048c409463..1c7cea0884a 100644 --- a/webapp/channels/src/components/admin_console/audits/audits.tsx +++ b/webapp/channels/src/components/admin_console/audits/audits.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {useEffect, useState, memo, useCallback} from 'react'; import type {CSSProperties} from 'react'; import {FormattedMessage, defineMessages} from 'react-intl'; @@ -23,10 +23,6 @@ type Props = { }; }; -type State = { - loadingAudits: boolean; -}; - const messages = defineMessages({ reload: {id: 'admin.audits.reload', defaultMessage: 'Reload User Activity Logs'}, }); @@ -35,29 +31,28 @@ export const searchableStrings = [ messages.reload, ]; -export default class Audits extends React.PureComponent { - public constructor(props: Props) { - super(props); +const Audits = ({ + isLicensed, + audits, + isDisabled, + actions, +}: Props) => { + const [isLoadingAudits, setIsLoadingAudits] = useState(true); - this.state = { - loadingAudits: true, - }; - } + useEffect(() => { + actions.getAudits().then(() => setIsLoadingAudits(false)); - public componentDidMount() { - this.props.actions.getAudits().then( - () => this.setState({loadingAudits: false}), - ); - } + /* eslint-disable-next-line react-hooks/exhaustive-deps -- + * This 'useEffect' should only run once during mount. + **/ + }, []); - private reload = () => { - this.setState({loadingAudits: true}); - this.props.actions.getAudits().then( - () => this.setState({loadingAudits: false}), - ); - }; + const reload = useCallback(() => { + setIsLoadingAudits(true); + actions.getAudits().then(() => setIsLoadingAudits(false)); + }, [actions]); - private activityLogHeader = () => { + const activityLogHeader = () => { const h4Style: CSSProperties = { display: 'inline-block', marginBottom: '6px', @@ -76,7 +71,7 @@ export default class Audits extends React.PureComponent { diff --git a/webapp/channels/src/components/admin_console/billing/billing_summary/billing_summary.tsx b/webapp/channels/src/components/admin_console/billing/billing_summary/billing_summary.tsx index 21c5e5a863d..8cf19eb289d 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_summary/billing_summary.tsx +++ b/webapp/channels/src/components/admin_console/billing/billing_summary/billing_summary.tsx @@ -82,7 +82,7 @@ export const FreeTrial = ({daysLeftOnTrial}: FreeTrialProps) => { {daysLeftOnTrial > TrialPeriodDays.TRIAL_1_DAY && } {(daysLeftOnTrial === TrialPeriodDays.TRIAL_1_DAY || daysLeftOnTrial === TrialPeriodDays.TRIAL_0_DAYS) && @@ -96,21 +96,21 @@ export const FreeTrial = ({daysLeftOnTrial}: FreeTrialProps) => { {daysLeftOnTrial > TrialPeriodDays.TRIAL_WARNING_THRESHOLD && } {(daysLeftOnTrial > TrialPeriodDays.TRIAL_1_DAY && daysLeftOnTrial <= TrialPeriodDays.TRIAL_WARNING_THRESHOLD) && } {(daysLeftOnTrial === TrialPeriodDays.TRIAL_1_DAY || daysLeftOnTrial === TrialPeriodDays.TRIAL_0_DAYS) && } diff --git a/webapp/channels/src/components/admin_console/billing/plan_details/feature_list.tsx b/webapp/channels/src/components/admin_console/billing/plan_details/feature_list.tsx index 19ad8becd8a..3d15558f198 100644 --- a/webapp/channels/src/components/admin_console/billing/plan_details/feature_list.tsx +++ b/webapp/channels/src/components/admin_console/billing/plan_details/feature_list.tsx @@ -32,7 +32,7 @@ const FeatureList = (props: FeatureListProps) => { intl.formatMessage( { id: 'admin.billing.subscription.planDetails.features.limitedFileStorage', - defaultMessage: 'Limited to {limit} File Storage', + defaultMessage: 'Limited to {limit} file storage', }, { diff --git a/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.tsx b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.tsx index 2999fb0ec23..ec6075175ee 100644 --- a/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.tsx +++ b/webapp/channels/src/components/admin_console/content_flagging/content_reviewers/team_reviewers_section/team_reviewers_section.tsx @@ -214,7 +214,6 @@ export default function TeamReviewers({teamReviewersSetting, onChange, disabled} {attributeKey === 'ldap' && ( {msg}, - }} /> )} @@ -132,7 +128,7 @@ const CustomProfileAttributes: React.FC = (props: Props): JSX.Element | n subtitle={ ( Site Configuration > Customization'}, }); diff --git a/webapp/channels/src/components/admin_console/data_grid/data_grid.tsx b/webapp/channels/src/components/admin_console/data_grid/data_grid.tsx index 41c566c194b..f8f33411cc2 100644 --- a/webapp/channels/src/components/admin_console/data_grid/data_grid.tsx +++ b/webapp/channels/src/components/admin_console/data_grid/data_grid.tsx @@ -46,7 +46,6 @@ type Props = { minimumColumnWidth?: number; - page: number; startCount: number; endCount: number; total?: number; diff --git a/webapp/channels/src/components/admin_console/data_retention_settings/__snapshots__/data_retention_settings.test.tsx.snap b/webapp/channels/src/components/admin_console/data_retention_settings/__snapshots__/data_retention_settings.test.tsx.snap index 0488315b512..799432de0d4 100644 --- a/webapp/channels/src/components/admin_console/data_retention_settings/__snapshots__/data_retention_settings.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/data_retention_settings/__snapshots__/data_retention_settings.test.tsx.snap @@ -77,7 +77,6 @@ exports[`components/admin_console/data_retention_settings/data_retention_setting endCount={4} loading={false} nextPage={[Function]} - page={0} previousPage={[Function]} rows={ Array [ @@ -209,7 +208,6 @@ exports[`components/admin_console/data_retention_settings/data_retention_setting endCount={1} loading={true} nextPage={[Function]} - page={0} previousPage={[Function]} rows={ Array [ @@ -445,7 +443,6 @@ exports[`components/admin_console/data_retention_settings/data_retention_setting endCount={4} loading={false} nextPage={[Function]} - page={0} previousPage={[Function]} rows={ Array [ @@ -587,7 +584,6 @@ exports[`components/admin_console/data_retention_settings/data_retention_setting endCount={1} loading={true} nextPage={[Function]} - page={0} previousPage={[Function]} rows={ Array [ @@ -828,7 +824,6 @@ exports[`components/admin_console/data_retention_settings/data_retention_setting endCount={4} loading={false} nextPage={[Function]} - page={0} previousPage={[Function]} rows={ Array [ @@ -970,7 +965,6 @@ exports[`components/admin_console/data_retention_settings/data_retention_setting endCount={1} loading={true} nextPage={[Function]} - page={0} previousPage={[Function]} rows={ Array [ @@ -1206,7 +1200,6 @@ exports[`components/admin_console/data_retention_settings/data_retention_setting endCount={4} loading={false} nextPage={[Function]} - page={0} previousPage={[Function]} rows={ Array [ @@ -1348,7 +1341,6 @@ exports[`components/admin_console/data_retention_settings/data_retention_setting endCount={0} loading={true} nextPage={[Function]} - page={0} previousPage={[Function]} rows={Array []} startCount={1} diff --git a/webapp/channels/src/components/admin_console/data_retention_settings/channel_list/__snapshots__/channel_list.test.tsx.snap b/webapp/channels/src/components/admin_console/data_retention_settings/channel_list/__snapshots__/channel_list.test.tsx.snap index 4cb94e1db87..2847d2d5627 100644 --- a/webapp/channels/src/components/admin_console/data_retention_settings/channel_list/__snapshots__/channel_list.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/data_retention_settings/channel_list/__snapshots__/channel_list.test.tsx.snap @@ -99,7 +99,6 @@ exports[`components/admin_console/data_retention_settings/channel_list should ma loading={false} nextPage={[Function]} onSearch={[Function]} - page={0} previousPage={[Function]} rows={ Array [ @@ -112,6 +111,7 @@ exports[`components/admin_console/data_retention_settings/channel_list should ma >
searchTerm='' filters={{}} onRemoveCallback={jest.fn()} - onAddCallback={jest.fn()} channelsToRemove={{}} channelsToAdd={{}} channels={testChannels} @@ -67,7 +66,6 @@ describe('components/admin_console/data_retention_settings/channel_list', () => searchTerm='' filters={{}} onRemoveCallback={jest.fn()} - onAddCallback={jest.fn()} channelsToRemove={{}} channelsToAdd={{}} channels={testChannels} diff --git a/webapp/channels/src/components/admin_console/data_retention_settings/channel_list/channel_list.tsx b/webapp/channels/src/components/admin_console/data_retention_settings/channel_list/channel_list.tsx index d23e7acf019..574772114c8 100644 --- a/webapp/channels/src/components/admin_console/data_retention_settings/channel_list/channel_list.tsx +++ b/webapp/channels/src/components/admin_console/data_retention_settings/channel_list/channel_list.tsx @@ -14,11 +14,8 @@ import DataGrid from 'components/admin_console/data_grid/data_grid'; import type {Column, Row} from 'components/admin_console/data_grid/data_grid'; import type {FilterOptions} from 'components/admin_console/filter/filter'; import TeamFilterDropdown from 'components/admin_console/filter/team_filter_dropdown'; -import ArchiveIcon from 'components/widgets/icons/archive_icon'; -import GlobeIcon from 'components/widgets/icons/globe_icon'; -import LockIcon from 'components/widgets/icons/lock_icon'; -import {isArchivedChannel} from 'utils/channel_utils'; +import {getChannelIconComponent} from 'utils/channel_utils'; import {Constants} from 'utils/constants'; import './channel_list.scss'; @@ -32,7 +29,6 @@ type Props = { policyId?: string; onRemoveCallback: (channel: ChannelWithTeamData) => void; - onAddCallback: (channels: ChannelWithTeamData[]) => void; channelsToRemove: Record; channelsToAdd: Record; @@ -189,19 +185,13 @@ export default class ChannelList extends React.PureComponent { } return channelsToDisplay.map((channel) => { - let iconToDisplay = ; - - if (channel.type === Constants.PRIVATE_CHANNEL) { - iconToDisplay = ; - } - if (isArchivedChannel(channel)) { - iconToDisplay = ( - - ); - } + const ChannelIconComponent = getChannelIconComponent(channel); + const iconToDisplay = ( + + ); return { cells: { id: channel.id, @@ -356,7 +346,6 @@ export default class ChannelList extends React.PureComponent { columns={columns} rows={rows} loading={this.state.loading} - page={this.state.page} nextPage={this.nextPage} previousPage={this.previousPage} startCount={startCount} diff --git a/webapp/channels/src/components/admin_console/data_retention_settings/custom_policy_form/__snapshots__/custom_policy_form.test.tsx.snap b/webapp/channels/src/components/admin_console/data_retention_settings/custom_policy_form/__snapshots__/custom_policy_form.test.tsx.snap index aad773dfd80..0f67594b184 100644 --- a/webapp/channels/src/components/admin_console/data_retention_settings/custom_policy_form/__snapshots__/custom_policy_form.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/data_retention_settings/custom_policy_form/__snapshots__/custom_policy_form.test.tsx.snap @@ -193,7 +193,6 @@ exports[`components/admin_console/data_retention_settings/custom_policy_form sho expanded={true} > @@ -455,7 +453,6 @@ exports[`components/admin_console/data_retention_settings/custom_policy_form sho expanded={true} > diff --git a/webapp/channels/src/components/admin_console/data_retention_settings/custom_policy_form/custom_policy_form.tsx b/webapp/channels/src/components/admin_console/data_retention_settings/custom_policy_form/custom_policy_form.tsx index f2832dffd1c..9a32a33d2d1 100644 --- a/webapp/channels/src/components/admin_console/data_retention_settings/custom_policy_form/custom_policy_form.tsx +++ b/webapp/channels/src/components/admin_console/data_retention_settings/custom_policy_form/custom_policy_form.tsx @@ -464,7 +464,6 @@ export default class CustomPolicyForm extends React.PureComponent > > { columns={this.getGlobalPolicyColumns()} rows={this.getGlobalPolicyRows()} loading={false} - page={0} nextPage={() => {}} previousPage={() => {}} startCount={1} @@ -573,7 +572,6 @@ class DataRetentionSettings extends React.PureComponent { columns={this.getCustomPolicyColumns()} rows={this.getCustomPolicyRows(startCount, endCount)} loading={this.state.customPoliciesLoading} - page={this.state.page} nextPage={this.nextPage} previousPage={this.previousPage} startCount={startCount} diff --git a/webapp/channels/src/components/admin_console/data_retention_settings/team_list/__snapshots__/team_list.test.tsx.snap b/webapp/channels/src/components/admin_console/data_retention_settings/team_list/__snapshots__/team_list.test.tsx.snap index 987edbd4423..9f7cc7a031a 100644 --- a/webapp/channels/src/components/admin_console/data_retention_settings/team_list/__snapshots__/team_list.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/data_retention_settings/team_list/__snapshots__/team_list.test.tsx.snap @@ -29,7 +29,6 @@ exports[`components/admin_console/data_retention_settings/team_list should match loading={false} nextPage={[Function]} onSearch={[Function]} - page={0} previousPage={[Function]} rows={ Array [ @@ -110,7 +109,6 @@ exports[`components/admin_console/data_retention_settings/team_list should match loading={false} nextPage={[Function]} onSearch={[Function]} - page={0} previousPage={[Function]} rows={ Array [ diff --git a/webapp/channels/src/components/admin_console/data_retention_settings/team_list/team_list.test.tsx b/webapp/channels/src/components/admin_console/data_retention_settings/team_list/team_list.test.tsx index f8fd178310d..2a6d4774c17 100644 --- a/webapp/channels/src/components/admin_console/data_retention_settings/team_list/team_list.test.tsx +++ b/webapp/channels/src/components/admin_console/data_retention_settings/team_list/team_list.test.tsx @@ -27,7 +27,6 @@ describe('components/admin_console/data_retention_settings/team_list', () => { { void; - onAddCallback: (users: Team[]) => void; teamsToRemove: Record; teamsToAdd: Record; @@ -249,7 +248,6 @@ export default class TeamList extends React.PureComponent { columns={columns} rows={rows} loading={this.state.loading} - page={this.state.page} nextPage={this.nextPage} previousPage={this.previousPage} startCount={startCount} diff --git a/webapp/channels/src/components/admin_console/database_settings.tsx b/webapp/channels/src/components/admin_console/database_settings.tsx index d7b1067951f..5d1ac1c8ef1 100644 --- a/webapp/channels/src/components/admin_console/database_settings.tsx +++ b/webapp/channels/src/components/admin_console/database_settings.tsx @@ -44,7 +44,7 @@ interface State extends BaseState { } const messages = defineMessages({ - title: {id: 'admin.database.title', defaultMessage: 'Database Settings'}, + title: {id: 'admin.database.title', defaultMessage: 'Database'}, recycleDescription: {id: 'admin.recycle.recycleDescription', defaultMessage: 'Deployments using multiple databases can switch from one master database to another without restarting the Mattermost server by updating "config.json" to the new desired configuration and using the {reloadConfiguration} feature to load the new settings while the server is running. The administrator should then use {featureName} feature to recycle the database connections based on the new settings.'}, featureName: {id: 'admin.recycle.recycleDescription.featureName', defaultMessage: 'Recycle Database Connections'}, reloadConfiguration: {id: 'admin.recycle.recycleDescription.reloadConfiguration', defaultMessage: 'Environment > Web Server > Reload Configuration from Disk'}, @@ -67,7 +67,7 @@ const messages = defineMessages({ connMaxIdleTimeTitle: {id: 'admin.sql.connMaxIdleTimeTitle', defaultMessage: 'Maximum Connection Idle Time:'}, connMaxIdleTimeDescription: {id: 'admin.sql.connMaxIdleTimeDescription', defaultMessage: 'Maximum idle time for a connection to the database in milliseconds.'}, minimumHashtagLengthTitle: {id: 'admin.service.minimumHashtagLengthTitle', defaultMessage: 'Minimum Hashtag Length:'}, - minimumHashtagLengthDescription: {id: 'admin.service.minimumHashtagLengthDescription', defaultMessage: 'Minimum number of characters in a hashtag. This must be greater than or equal to 2. MySQL databases must be configured to support searching strings shorter than three characters, see documentation.'}, + minimumHashtagLengthDescription: {id: 'admin.service.minimumHashtagLengthDescription', defaultMessage: 'Minimum number of characters in a hashtag. This must be greater than or equal to 2.'}, traceTitle: {id: 'admin.sql.traceTitle', defaultMessage: 'SQL Statement Logging: '}, traceDescription: {id: 'admin.sql.traceDescription', defaultMessage: '(Development Mode) When true, executing SQL statements are written to the log.'}, }); @@ -187,6 +187,7 @@ export default class DatabaseSettings extends OLDAdminSettings { showSuccessMessage={false} errorMessage={defineMessage({ id: 'admin.recycle.reloadFail', + // eslint-disable-next-line formatjs/enforce-placeholders -- error provided by RequestButton defaultMessage: 'Recycling unsuccessful: {error}', })} includeDetailedError={true} @@ -320,19 +321,7 @@ export default class DatabaseSettings extends OLDAdminSettings { } placeholder={defineMessage({id: 'admin.service.minimumHashtagLengthExample', defaultMessage: 'E.g.: "3"'})} helpText={ - ( - - {msg} - - ), - }} - /> + } value={this.state.minimumHashtagLength} onChange={this.handleChange} diff --git a/webapp/channels/src/components/admin_console/elasticsearch_settings.tsx b/webapp/channels/src/components/admin_console/elasticsearch_settings.tsx index 1bf248d975c..4df1cbb96c0 100644 --- a/webapp/channels/src/components/admin_console/elasticsearch_settings.tsx +++ b/webapp/channels/src/components/admin_console/elasticsearch_settings.tsx @@ -65,18 +65,18 @@ export const messages = defineMessages({ bulkIndexingTitle: {id: 'admin.elasticsearch.bulkIndexingTitle', defaultMessage: 'Bulk Indexing:'}, help: {id: 'admin.elasticsearch.createJob.help', defaultMessage: 'All users, channels and posts in the database will be indexed from oldest to newest. Elasticsearch is available during indexing but search results may be incomplete until the indexing job is complete.'}, rebuildChannelsIndexTitle: {id: 'admin.elasticsearch.rebuildChannelsIndexTitle', defaultMessage: 'Rebuild Channels Index'}, - rebuildChannelIndexHelpText: {id: 'admin.elasticsearch.rebuildChannelsIndex.helpText', defaultMessage: 'This purges the channels index and re-indexes all channels in the database, from oldest to newest. Channel autocomplete is available during indexing but search results may be incomplete until the indexing job is complete.\nNote- Please ensure no other indexing job is in progress in the table above.'}, + rebuildChannelIndexHelpText: {id: 'admin.elasticsearch.rebuildChannelsIndex.helpText', defaultMessage: 'This purges the channels index and re-indexes all channels in the database, from oldest to newest. Channel autocomplete is available during indexing but search results may be incomplete until the indexing job is complete.\n\nNote- Please ensure no other indexing job is in progress in the table above.'}, // eslint-disable-line formatjs/no-multiple-whitespaces rebuildChannelsIndexButtonText: {id: 'admin.elasticsearch.rebuildChannelsIndex.title', defaultMessage: 'Rebuild Channels Index'}, purgeIndexesHelpText: {id: 'admin.elasticsearch.purgeIndexesHelpText', defaultMessage: 'Purging will entirely remove the indexes on the Elasticsearch server. Search results may be incomplete until a bulk index of the existing database is rebuilt.'}, - purgeIndexesButton: {id: 'admin.elasticsearch.purgeIndexesButton', defaultMessage: 'Purge Index'}, + purgeIndexesButton: {id: 'admin.elasticsearch.purgeIndexesButton', defaultMessage: 'Purge Indexes'}, label: {id: 'admin.elasticsearch.purgeIndexesButton.label', defaultMessage: 'Purge Indexes:'}, enableSearchingTitle: {id: 'admin.elasticsearch.enableSearchingTitle', defaultMessage: 'Enable Elasticsearch for search queries:'}, enableSearchingDescription: {id: 'admin.elasticsearch.enableSearchingDescription', defaultMessage: 'Requires a successful connection to the Elasticsearch server. When true, Elasticsearch will be used for all search queries using the latest index. Search results may be incomplete until a bulk index of the existing post database is finished. When false, database search is used.'}, }); export const searchableStrings: Array = [ - [messages.connectionUrlDescription, {documentationLink: ''}], - [messages.enableIndexingDescription, {documentationLink: ''}], + [messages.connectionUrlDescription, {link: (msg: string) => msg}], + [messages.enableIndexingDescription, {link: (msg: string) => msg}], messages.title, messages.enableIndexingTitle, messages.connectionUrlTitle, @@ -456,6 +456,7 @@ export default class ElasticsearchSettings extends OLDAdminSettings { expect(screen.getByRole('button', {name: 'Start trial'})).toBeInTheDocument(); await userEvent.click(screen.getByRole('button', {name: 'Start trial'})); - await userEvent.click(screen.getByText('Mattermost Software and Services License Agreement')); + await userEvent.click(screen.getByText('Mattermost Software Evaluation Agreement')); //cloud option expect(screen.queryByRole('button', {name: 'Try free for 30 days'})).not.toBeInTheDocument(); @@ -68,7 +68,7 @@ describe('components/feature_discovery', () => { expect(featureLink).toBeInTheDocument(); expect(featureLink).toHaveAttribute('href', 'https://test.mattermost.com/secondary/?utm_source=mattermost&utm_medium=in-product&utm_content=feature_discovery&uid=&sid=&edition=team&server_version='); expect(featureLink).toHaveTextContent('Learn more'); - expect(screen.getByText('Mattermost Software and Services License Agreement')).toHaveAttribute('href', 'https://mattermost.com/pl/software-and-services-license-agreement?utm_source=mattermost&utm_medium=in-product&utm_content=feature_discovery&uid=&sid=&edition=team&server_version='); + expect(screen.getByText('Mattermost Software Evaluation Agreement')).toHaveAttribute('href', 'https://mattermost.com/pl/software-and-services-license-agreement?utm_source=mattermost&utm_medium=in-product&utm_content=feature_discovery&uid=&sid=&edition=team&server_version='); expect(screen.getByText('Privacy Policy')).toHaveAttribute('href', AboutLinks.PRIVACY_POLICY + '?utm_source=mattermost&utm_medium=in-product&utm_content=feature_discovery&uid=&sid=&edition=team&server_version='); expect(getPrevTrialLicense).toHaveBeenCalled(); diff --git a/webapp/channels/src/components/admin_console/feature_discovery/feature_discovery.tsx b/webapp/channels/src/components/admin_console/feature_discovery/feature_discovery.tsx index 6a62855f2e3..d5b78984bdb 100644 --- a/webapp/channels/src/components/admin_console/feature_discovery/feature_discovery.tsx +++ b/webapp/channels/src/components/admin_console/feature_discovery/feature_discovery.tsx @@ -167,7 +167,7 @@ export default class FeatureDiscovery extends React.PureComponent ( {msg} diff --git a/webapp/channels/src/components/admin_console/feature_discovery/features/auto_translation.tsx b/webapp/channels/src/components/admin_console/feature_discovery/features/auto_translation.tsx index 01ce27c2add..ee52e3fb052 100644 --- a/webapp/channels/src/components/admin_console/feature_discovery/features/auto_translation.tsx +++ b/webapp/channels/src/components/admin_console/feature_discovery/features/auto_translation.tsx @@ -31,6 +31,7 @@ const AutoTranslationFeatureDiscovery: React.FC = () => { })} copy={defineMessage({ id: 'admin.auto_translation_feature_discovery.copy', + // eslint-disable-next-line formatjs/enforce-placeholders -- values spread onto FormattedMessage in FeatureDiscovery defaultMessage: 'Effortlessly collaborate across languages with auto-translation. Messages in shared channels are instantly translated based on each user’s language preference—no extra steps required.{br}Only available in Enterprise Advanced.', values: {strong: (msg: string) => {msg}, br:
}, })} diff --git a/webapp/channels/src/components/admin_console/feature_discovery/features/images/auto_translate_svg.tsx b/webapp/channels/src/components/admin_console/feature_discovery/features/images/auto_translate_svg.tsx index 39094e95da0..c89589dea44 100644 --- a/webapp/channels/src/components/admin_console/feature_discovery/features/images/auto_translate_svg.tsx +++ b/webapp/channels/src/components/admin_console/feature_discovery/features/images/auto_translate_svg.tsx @@ -21,13 +21,13 @@ const AutoTranslationSVG = (props: SvgProps) => ( cx={89.0743} cy={80.0743} r={59.5743} - fill='white' + fill='var(--center-channel-bg)' /> @@ -35,147 +35,147 @@ const AutoTranslationSVG = (props: SvgProps) => ( cx={91} cy={82} r={57} - fill='#1C58D9' + fill='var(--button-bg)' fillOpacity={0.08} /> ( > ( rx='3' ry='3' transform='matrix(1 8.74228e-08 8.74228e-08 -1 15.03 6)' - fill='#3F4350' + fill='var(--center-channel-color)' fillOpacity='0.48' /> ( cy='59.2227' r='3.37477' transform='rotate(180 278.655 59.2227)' - fill='#3F4350' + fill='var(--center-channel-color)' fillOpacity='0.48' /> ( width='196.5' height='129' rx='6' - fill='white' - stroke='#3F4350' + fill='var(--center-channel-bg)' + stroke='var(--center-channel-color)' strokeWidth='6' /> ( height='55.5' rx='7.53' transform='matrix(1 0 0 -1 219.03 135)' - fill='#3F4350' + fill='var(--center-channel-color)' fillOpacity='0.16' /> ( width='49.9482' height='94.0884' rx='8.71189' - fill='white' - stroke='#3F4350' + fill='var(--center-channel-bg)' + stroke='var(--center-channel-color)' strokeWidth='3.48476' /> @@ -376,124 +376,124 @@ const IntuneMAMSvg = (props: SvgProps) => ( height='97.5' rx='5.63698' transform='rotate(90 99.75 42.75)' - fill='white' - stroke='#3F4350' + fill='var(--center-channel-bg)' + stroke='var(--center-channel-color)' strokeWidth='4.5' /> ( fill='none' xmlns='http://www.w3.org/2000/svg' > - + @@ -28,24 +28,24 @@ const MobileSecuritySVG = (props: SvgProps) => ( cy='60.5' rx='59.5' ry='59.5' - fill='#1C58D9' + fill='var(--button-bg)' fillOpacity='0.08' /> ( rx='1.5' ry='1.5' transform='rotate(180 131 101.5)' - fill='#3F4350' + fill='var(--center-channel-color)' fillOpacity='0.48' /> ( rx='1.5' ry='1.5' transform='matrix(1 8.74228e-08 8.74228e-08 -1 157 70)' - fill='#3F4350' + fill='var(--center-channel-color)' fillOpacity='0.48' /> @@ -158,13 +158,13 @@ const MobileSecuritySVG = (props: SvgProps) => ( fillRule='evenodd' clipRule='evenodd' d='M87.0013 57.3883H108.118C109.095 57.3883 109.885 58.1742 109.885 59.1484V66.9186C109.885 73.5528 106.909 78.9321 100.259 78.9321H94.8606C88.2109 78.9321 85.2349 73.5528 85.2349 66.9186V59.1484C85.2349 58.1775 86.0273 57.3883 87.0013 57.3883ZM97.1724 61.9248H96.2348V64.127V68.0017L97.478 69.2449L96.2348 70.4881V72.1297H99.6365V65.3642L98.6858 64.4136L99.6365 63.4629V61.9248H97.1724Z' - fill='#1C58D9' + fill='var(--button-bg)' /> ( width='43' height='81' rx='7.5' - fill='white' - stroke='#3F4350' + fill='var(--center-channel-bg)' + stroke='var(--center-channel-color)' strokeWidth='3' /> @@ -238,7 +238,7 @@ const MobileSecuritySVG = (props: SvgProps) => ( width='16' height='1' rx='0.5' - fill='#3F4350' + fill='var(--center-channel-color)' /> ( width='45.8049' height='78.0488' rx='3.5' - fill='white' - stroke='#3F4350' + fill='var(--center-channel-bg)' + stroke='var(--center-channel-color)' /> - - - - - ); diff --git a/webapp/channels/src/components/admin_console/feature_flags.tsx b/webapp/channels/src/components/admin_console/feature_flags.tsx index 7563c19a559..6e30c61c12b 100644 --- a/webapp/channels/src/components/admin_console/feature_flags.tsx +++ b/webapp/channels/src/components/admin_console/feature_flags.tsx @@ -13,7 +13,7 @@ type Props = { }; export const messages = defineMessages({ - title: {id: 'admin.feature_flags.title', defaultMessage: 'Features Flags'}, + title: {id: 'admin.feature_flags.title', defaultMessage: 'Feature Flags'}, }); const FeatureFlags: React.FC = (props: Props) => { @@ -40,7 +40,7 @@ const FeatureFlags: React.FC = (props: Props) => {
diff --git a/webapp/channels/src/components/admin_console/file_upload_setting.tsx b/webapp/channels/src/components/admin_console/file_upload_setting.tsx index 9a0ec7c8b26..7eeee8c2de2 100644 --- a/webapp/channels/src/components/admin_console/file_upload_setting.tsx +++ b/webapp/channels/src/components/admin_console/file_upload_setting.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {memo, useCallback, useEffect, useRef, useState} from 'react'; import {FormattedMessage} from 'react-intl'; import * as Utils from 'utils/utils'; @@ -19,134 +19,143 @@ type Props = { error?: string; } -type State = { - fileName: null|string; - fileSelected: boolean; - serverError?: string; - uploading: boolean; -} +const FileUploadSetting = ({ + id, + error: errorFromProps, + label, + helpText, + disabled, + fileType, + uploadingText, + onSubmit, +}: Props) => { + const [fileNameFromState, setFileNameFromState] = useState(null); -export default class FileUploadSetting extends React.PureComponent { - fileInputRef = React.createRef(); + const [isUploading, setIsUploading] = useState(false); + const [isFileSelected, setIsFileSelected] = useState(false); + + const fileInputRef = useRef(null); // Helps prevent setting state after component is unmounted, for usage when this component is wrapped by a custom setting - isMounted = false; + const isMounted = useRef(false); - constructor(props: Props) { - super(props); + useEffect(() => { + isMounted.current = true; - this.state = { - fileName: null, - serverError: props.error, - uploading: false, - fileSelected: false, + return () => { + isMounted.current = false; }; - } + }, []); - componentDidMount() { - this.isMounted = true; - } + const handleChooseClick = useCallback(() => { + fileInputRef.current?.click(); + }, []); - componentWillUnmount() { - this.isMounted = false; - } - - handleChooseClick = () => { - this.fileInputRef.current?.click(); - }; - - handleChange = () => { - const files = this.fileInputRef.current?.files; + const handleChange = useCallback(() => { + const files = fileInputRef.current?.files; if (files && files.length > 0) { - this.setState({fileSelected: true, fileName: files[0].name}); + setIsFileSelected(true); + setFileNameFromState(files[0].name); } - }; + }, []); - handleSubmit = (e: React.MouseEvent) => { + const handleSubmit = useCallback((e: React.MouseEvent) => { e.preventDefault(); - this.setState({uploading: true}); - const file = this.fileInputRef.current?.files?.[0]; + setIsUploading(true); + const file = fileInputRef.current?.files?.[0]; if (file) { - this.props.onSubmit(this.props.id, file, (error) => { - if (this.isMounted) { - this.setState({uploading: false}); - if (error && this.fileInputRef.current) { - Utils.clearFileInput(this.fileInputRef.current); + onSubmit(id, file, (error) => { + if (isMounted.current) { + setIsUploading(false); + + if (error && fileInputRef.current) { + Utils.clearFileInput(fileInputRef.current); } } }); } - }; + }, [id, onSubmit]); - render() { - let serverError; - if (this.state.serverError) { - serverError =
; - } - - let fileName; - if (this.state.fileName) { - fileName = this.state.fileName; - } else { - fileName = ( - - ); - } - - return ( - -
-
- - -
- -
- {fileName} -
- {serverError} -
-
+ let serverError; + if (errorFromProps) { + serverError = ( +
+ +
); } -} + + let fileName; + if (fileNameFromState) { + fileName = fileNameFromState; + } else { + fileName = ( + + ); + } + + return ( + +
+
+ + +
+ +
+ {fileName} +
+ {serverError} +
+
+ ); +}; + +export default memo(FileUploadSetting); diff --git a/webapp/channels/src/components/admin_console/group_settings/group_details/group_details.tsx b/webapp/channels/src/components/admin_console/group_settings/group_details/group_details.tsx index d4803835543..e79ba559f38 100644 --- a/webapp/channels/src/components/admin_console/group_settings/group_details/group_details.tsx +++ b/webapp/channels/src/components/admin_console/group_settings/group_details/group_details.tsx @@ -479,7 +479,7 @@ class GroupDetails extends React.PureComponent { serverError = ( ); } else if ( diff --git a/webapp/channels/src/components/admin_console/group_settings/group_details/group_profile_and_settings.tsx b/webapp/channels/src/components/admin_console/group_settings/group_details/group_profile_and_settings.tsx index 3dbe8a1db0f..7c2c301ca8b 100644 --- a/webapp/channels/src/components/admin_console/group_settings/group_details/group_profile_and_settings.tsx +++ b/webapp/channels/src/components/admin_console/group_settings/group_details/group_profile_and_settings.tsx @@ -36,7 +36,7 @@ const GroupSettingsToggle = ({ title={ } subTitle={ diff --git a/webapp/channels/src/components/admin_console/group_settings/group_settings.tsx b/webapp/channels/src/components/admin_console/group_settings/group_settings.tsx index cd5cc1230c7..b360ee58a74 100644 --- a/webapp/channels/src/components/admin_console/group_settings/group_settings.tsx +++ b/webapp/channels/src/components/admin_console/group_settings/group_settings.tsx @@ -51,6 +51,7 @@ const GroupSettings = ({isDisabled}: Props) => { AD/LDAP configuration page.'})} subtitleValues={{ link: (msg: React.ReactNode) => ( diff --git a/webapp/channels/src/components/admin_console/index.ts b/webapp/channels/src/components/admin_console/index.ts index 2b5b7b0298e..794b05ebc0a 100644 --- a/webapp/channels/src/components/admin_console/index.ts +++ b/webapp/channels/src/components/admin_console/index.ts @@ -12,7 +12,6 @@ import {selectTeam} from 'mattermost-redux/actions/teams'; import {General} from 'mattermost-redux/constants'; import * as Selectors from 'mattermost-redux/selectors/entities/admin'; import {getConfig as getGeneralConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; -import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; import {getRoles} from 'mattermost-redux/selectors/entities/roles'; import {getTeam} from 'mattermost-redux/selectors/entities/teams'; import {isCurrentUserSystemAdmin, currentUserHasAnAdminRole, getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; @@ -51,7 +50,6 @@ function mapStateToProps(state: GlobalState) { consoleAccess, cloud: state.entities.cloud, team, - currentTheme: getTheme(state), }; } diff --git a/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_no_filters_panel.tsx b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_no_filters_panel.tsx index 40e3f0b651c..c46a98f249f 100644 --- a/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_no_filters_panel.tsx +++ b/webapp/channels/src/components/admin_console/ip_filtering/edit_section/edit_section_no_filters_panel.tsx @@ -12,10 +12,10 @@ type NoFiltersPanelProps = { const NoFiltersPanel = ({setShowAddModal}: NoFiltersPanelProps) => (
-
+
@@ -27,7 +27,7 @@ const NoFiltersPanel = ({setShowAddModal}: NoFiltersPanelProps) => (
(
void; 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 index 21893b2a686..3e3a3716ff9 100644 --- a/webapp/channels/src/components/admin_console/ldap_wizard/ldap_wizard.tsx +++ b/webapp/channels/src/components/admin_console/ldap_wizard/ldap_wizard.tsx @@ -221,7 +221,6 @@ const LDAPWizard = (props: Props) => { return ( - +
); diff --git a/webapp/channels/src/components/admin_console/license_settings/modals/upload_license_modal.tsx b/webapp/channels/src/components/admin_console/license_settings/modals/upload_license_modal.tsx index f68cb632062..d00c1b2f891 100644 --- a/webapp/channels/src/components/admin_console/license_settings/modals/upload_license_modal.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/modals/upload_license_modal.tsx @@ -19,7 +19,7 @@ import {isModalOpen} from 'selectors/views/modals'; import FileSvg from 'components/common/svg_images_components/file_svg'; import SuccessSvg from 'components/common/svg_images_components/success_svg'; -import UploadLicenseSvg from 'components/common/svg_images_components/upload_license'; +import UploadLicenseSvg from 'components/common/svg_images_components/upload_license_svg'; import LoadingWrapper from 'components/widgets/loading/loading_wrapper'; import {FileTypes, ModalIdentifiers} from 'utils/constants'; diff --git a/webapp/channels/src/components/admin_console/license_settings/renew_license_card/renew_license_card.tsx b/webapp/channels/src/components/admin_console/license_settings/renew_license_card/renew_license_card.tsx index fe2052fe1d9..d4ce3512693 100644 --- a/webapp/channels/src/components/admin_console/license_settings/renew_license_card/renew_license_card.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/renew_license_card/renew_license_card.tsx @@ -28,7 +28,6 @@ const RenewLicenseCard: React.FC = ({license, totalUsers, const contactSalesBtn = (
diff --git a/webapp/channels/src/components/admin_console/license_settings/starter_edition/starter_right_panel.tsx b/webapp/channels/src/components/admin_console/license_settings/starter_edition/starter_right_panel.tsx index 063e5f9afc7..545ec0e2f39 100644 --- a/webapp/channels/src/components/admin_console/license_settings/starter_edition/starter_right_panel.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/starter_edition/starter_right_panel.tsx @@ -5,7 +5,7 @@ import React, {memo} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; import ContactUsButton from 'components/announcement_bar/contact_sales/contact_us'; -import SetupSystemSvg from 'components/common/svg_images_components/setup_system'; +import SetupSystemSvg from 'components/common/svg_images_components/setup_system_svg'; const StarterRightPanel = () => { const intl = useIntl(); @@ -69,7 +69,6 @@ const StarterRightPanel = () => {
diff --git a/webapp/channels/src/components/admin_console/license_settings/team_edition/team_edition_right_panel.tsx b/webapp/channels/src/components/admin_console/license_settings/team_edition/team_edition_right_panel.tsx index 7f1164093d2..44fef52548b 100644 --- a/webapp/channels/src/components/admin_console/license_settings/team_edition/team_edition_right_panel.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/team_edition/team_edition_right_panel.tsx @@ -4,7 +4,7 @@ import React from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; -import SetupSystemSvg from 'components/common/svg_images_components/setup_system'; +import SetupSystemSvg from 'components/common/svg_images_components/setup_system_svg'; import ExternalLink from 'components/external_link'; import LoadingWrapper from 'components/widgets/loading/loading_wrapper'; diff --git a/webapp/channels/src/components/admin_console/license_settings/trial_banner/trial_banner.tsx b/webapp/channels/src/components/admin_console/license_settings/trial_banner/trial_banner.tsx index 9b9f94d5a0a..4e6e83e4174 100644 --- a/webapp/channels/src/components/admin_console/license_settings/trial_banner/trial_banner.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/trial_banner/trial_banner.tsx @@ -2,7 +2,6 @@ // See LICENSE.txt for license information. import React, {useEffect, useState} from 'react'; -import type {ReactNode} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; @@ -108,7 +107,7 @@ const TrialBanner = ({ case TrialLoadStatus.Failed: return formatMessage({id: 'start_trial.modal.failed', defaultMessage: 'Failed'}); case TrialLoadStatus.Embargoed: - return formatMessage( + return formatMessage( { id: 'admin.license.trial-request.embargoed', defaultMessage: 'We were unable to process the request due to limitations for embargoed countries. Learn more in our documentation, or reach out to legal@mattermost.com for questions around export limitations.', @@ -342,7 +341,7 @@ const TrialBanner = ({

{msg}, linkEvaluation: (msg: React.ReactNode) => ( diff --git a/webapp/channels/src/components/admin_console/localization/auto_translation.tsx b/webapp/channels/src/components/admin_console/localization/auto_translation.tsx index 6f5e798572e..1b01ce8123a 100644 --- a/webapp/channels/src/components/admin_console/localization/auto_translation.tsx +++ b/webapp/channels/src/components/admin_console/localization/auto_translation.tsx @@ -112,6 +112,7 @@ export default function AutoTranslation(props: SystemConsoleCustomSettingsCompon helpText={ diff --git a/webapp/channels/src/components/admin_console/member_list_group/__snapshots__/member_list_group.test.tsx.snap b/webapp/channels/src/components/admin_console/member_list_group/__snapshots__/member_list_group.test.tsx.snap index 1e7d9709696..11b74ed5525 100644 --- a/webapp/channels/src/components/admin_console/member_list_group/__snapshots__/member_list_group.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/member_list_group/__snapshots__/member_list_group.test.tsx.snap @@ -20,7 +20,6 @@ exports[`admin_console/team_channel_settings/group/GroupList should match snapsh loading={true} nextPage={[Function]} onSearch={[MockFunction]} - page={0} placeholderEmpty={ { columns={columns} rows={rows} loading={this.state.loading} - page={this.state.page} nextPage={this.nextPage} previousPage={this.previousPage} startCount={startCount} diff --git a/webapp/channels/src/components/admin_console/message_export_settings.tsx b/webapp/channels/src/components/admin_console/message_export_settings.tsx index cdf47519356..3ed095dd43f 100644 --- a/webapp/channels/src/components/admin_console/message_export_settings.tsx +++ b/webapp/channels/src/components/admin_console/message_export_settings.tsx @@ -193,7 +193,7 @@ export class MessageExportSettings extends OLDAdminSettings Inherited from All Members diff --git a/webapp/channels/src/components/admin_console/permission_schemes_settings/__snapshots__/permission_schemes_settings.test.tsx.snap b/webapp/channels/src/components/admin_console/permission_schemes_settings/__snapshots__/permission_schemes_settings.test.tsx.snap index 1988a6f5ae7..f92518e8cb9 100644 --- a/webapp/channels/src/components/admin_console/permission_schemes_settings/__snapshots__/permission_schemes_settings.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/permission_schemes_settings/__snapshots__/permission_schemes_settings.test.tsx.snap @@ -76,7 +76,7 @@ exports[`components/admin_console/permission_schemes_settings/permission_schemes } subtitle={ Object { - "defaultMessage": "Use when specific teams need permission exceptions to the System Scheme", + "defaultMessage": "Use when specific teams need permission exceptions to the System Scheme.", "id": "admin.permissions.teamOverrideSchemesBannerText", } } @@ -203,7 +203,7 @@ exports[`components/admin_console/permission_schemes_settings/permission_schemes } subtitle={ Object { - "defaultMessage": "Use when specific teams need permission exceptions to the System Scheme", + "defaultMessage": "Use when specific teams need permission exceptions to the System Scheme.", "id": "admin.permissions.teamOverrideSchemesBannerText", } } @@ -309,7 +309,7 @@ exports[`components/admin_console/permission_schemes_settings/permission_schemes } subtitle={ Object { - "defaultMessage": "Use when specific teams need permission exceptions to the System Scheme", + "defaultMessage": "Use when specific teams need permission exceptions to the System Scheme.", "id": "admin.permissions.teamOverrideSchemesBannerText", } } @@ -454,7 +454,7 @@ exports[`components/admin_console/permission_schemes_settings/permission_schemes } subtitle={ Object { - "defaultMessage": "Use when specific teams need permission exceptions to the System Scheme", + "defaultMessage": "Use when specific teams need permission exceptions to the System Scheme.", "id": "admin.permissions.teamOverrideSchemesBannerText", } } @@ -598,7 +598,7 @@ exports[`components/admin_console/permission_schemes_settings/permission_schemes } subtitle={ Object { - "defaultMessage": "Use when specific teams need permission exceptions to the System Scheme", + "defaultMessage": "Use when specific teams need permission exceptions to the System Scheme.", "id": "admin.permissions.teamOverrideSchemesBannerText", } } diff --git a/webapp/channels/src/components/admin_console/permission_schemes_settings/permission_row.tsx b/webapp/channels/src/components/admin_console/permission_schemes_settings/permission_row.tsx index cc6791be3bd..ecfbc90f4ea 100644 --- a/webapp/channels/src/components/admin_console/permission_schemes_settings/permission_row.tsx +++ b/webapp/channels/src/components/admin_console/permission_schemes_settings/permission_row.tsx @@ -46,7 +46,7 @@ const PermissionRow = ({ if (permissionRolesStrings[id]) { description = ( ); diff --git a/webapp/channels/src/components/admin_console/permission_schemes_settings/permission_schemes_settings.tsx b/webapp/channels/src/components/admin_console/permission_schemes_settings/permission_schemes_settings.tsx index 11d7229d693..4aae57563b9 100644 --- a/webapp/channels/src/components/admin_console/permission_schemes_settings/permission_schemes_settings.tsx +++ b/webapp/channels/src/components/admin_console/permission_schemes_settings/permission_schemes_settings.tsx @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable formatjs/enforce-placeholders -- link placeholders provided by admin panel components */ + import React from 'react'; import {type MessageDescriptor} from 'react-intl'; import {FormattedMessage, defineMessage, defineMessages} from 'react-intl'; @@ -53,7 +55,7 @@ const messages = defineMessages({ systemSchemeBannerText: {id: 'admin.permissions.systemSchemeBannerText', defaultMessage: 'Set the default permissions inherited by all teams unless a Team Override Scheme is applied.'}, systemSchemeBannerButton: {id: 'admin.permissions.systemSchemeBannerButton', defaultMessage: 'Edit Scheme'}, teamOverrideSchemesTitle: {id: 'admin.permissions.teamOverrideSchemesTitle', defaultMessage: 'Team Override Schemes'}, - teamOverrideSchemesBannerText: {id: 'admin.permissions.teamOverrideSchemesBannerText', defaultMessage: 'Use when specific teams need permission exceptions to the System Scheme'}, + teamOverrideSchemesBannerText: {id: 'admin.permissions.teamOverrideSchemesBannerText', defaultMessage: 'Use when specific teams need permission exceptions to the System Scheme.'}, teamOverrideSchemesNewButton: {id: 'admin.permissions.teamOverrideSchemesNewButton', defaultMessage: 'New Team Override Scheme'}, }); diff --git a/webapp/channels/src/components/admin_console/permission_schemes_settings/permission_system_scheme_settings/__snapshots__/permission_system_scheme_settings.test.tsx.snap b/webapp/channels/src/components/admin_console/permission_schemes_settings/permission_system_scheme_settings/__snapshots__/permission_system_scheme_settings.test.tsx.snap index 8800cf284aa..c8ca4098ee3 100644 --- a/webapp/channels/src/components/admin_console/permission_schemes_settings/permission_system_scheme_settings/__snapshots__/permission_system_scheme_settings.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/permission_schemes_settings/permission_system_scheme_settings/__snapshots__/permission_system_scheme_settings.test.tsx.snap @@ -175,7 +175,7 @@ exports[`components/admin_console/permission_schemes_settings/permission_system_ > { ( team.id)} /> } diff --git a/webapp/channels/src/components/admin_console/plugin_management/__snapshots__/plugin_management.test.tsx.snap b/webapp/channels/src/components/admin_console/plugin_management/__snapshots__/plugin_management.test.tsx.snap index b87a187a330..67021ed5537 100644 --- a/webapp/channels/src/components/admin_console/plugin_management/__snapshots__/plugin_management.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/plugin_management/__snapshots__/plugin_management.test.tsx.snap @@ -11,7 +11,7 @@ exports[`components/PluginManagement should match snapshot 1`] = ` > @@ -270,7 +270,7 @@ exports[`components/PluginManagement should match snapshot when \`Enable Marketp > @@ -529,7 +529,7 @@ exports[`components/PluginManagement should match snapshot when \`Enable Plugins > @@ -605,7 +605,7 @@ exports[`components/PluginManagement should match snapshot when \`Enable Remote > @@ -864,7 +864,7 @@ exports[`components/PluginManagement should match snapshot when \`Require Signat > @@ -1125,7 +1125,7 @@ exports[`components/PluginManagement should match snapshot, No installed plugins > @@ -1388,7 +1388,7 @@ exports[`components/PluginManagement should match snapshot, allow insecure URL e > @@ -1647,7 +1647,7 @@ exports[`components/PluginManagement should match snapshot, disabled 1`] = ` > @@ -1730,11 +1730,12 @@ exports[`components/PluginManagement should match snapshot, disabled 1`] = ` @@ -1890,7 +1891,7 @@ exports[`components/PluginManagement should match snapshot, text entered into th > @@ -2149,7 +2150,7 @@ exports[`components/PluginManagement should match snapshot, upload disabled 1`] > @@ -2415,7 +2416,7 @@ exports[`components/PluginManagement should match snapshot, with installed plugi > @@ -2737,7 +2738,7 @@ exports[`components/PluginManagement should match snapshot, with installed plugi > @@ -3027,7 +3028,7 @@ exports[`components/PluginManagement should match snapshot, with installed plugi > @@ -3317,7 +3318,7 @@ exports[`components/PluginManagement should match snapshot, with installed plugi > @@ -3607,7 +3608,7 @@ exports[`components/PluginManagement should match snapshot, with installed plugi > diff --git a/webapp/channels/src/components/admin_console/plugin_management/plugin_management.tsx b/webapp/channels/src/components/admin_console/plugin_management/plugin_management.tsx index 34ffe0fdf17..bc5ce96146b 100644 --- a/webapp/channels/src/components/admin_console/plugin_management/plugin_management.tsx +++ b/webapp/channels/src/components/admin_console/plugin_management/plugin_management.tsx @@ -189,7 +189,7 @@ type PluginItemProps = { }; const messages = defineMessages({ - title: {id: 'admin.plugin.management.title', defaultMessage: 'Management'}, + title: {id: 'admin.plugin.management.title', defaultMessage: 'Plugin Management'}, enable: {id: 'admin.plugins.settings.enable', defaultMessage: 'Enable Plugins: '}, enableDesc: {id: 'admin.plugins.settings.enableDesc', defaultMessage: 'When true, enables plugins on your Mattermost server. Use plugins to integrate with third-party systems, extend functionality, or customize the user interface of your Mattermost server. See documentation to learn more.'}, uploadTitle: {id: 'admin.plugin.uploadTitle', defaultMessage: 'Upload Plugin: '}, @@ -1073,7 +1073,7 @@ class PluginManagement extends OLDAdminSettings { uploadHelpText = ( ( { {msg} ), + strong: (msg: React.ReactNode) => {msg}, }} /> ); diff --git a/webapp/channels/src/components/admin_console/push_settings.tsx b/webapp/channels/src/components/admin_console/push_settings.tsx index 078070e424f..a2aee49217d 100644 --- a/webapp/channels/src/components/admin_console/push_settings.tsx +++ b/webapp/channels/src/components/admin_console/push_settings.tsx @@ -389,14 +389,14 @@ class PushSettings extends OLDAdminSettings { label={ } placeholder={defineMessage({id: 'admin.team.maxNotificationsPerChannelExample', defaultMessage: 'E.g.: "1000"'})} helpText={ } value={this.state.maxNotificationsPerChannel} 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 d1ecc37efef..80ec0df22e8 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 @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +/* eslint-disable formatjs/enforce-placeholders -- Admin request button uses runtime injection for error placeholder */ + import React from 'react'; import type {MessageDescriptor} from 'react-intl'; import {FormattedMessage, defineMessage} from 'react-intl'; diff --git a/webapp/channels/src/components/admin_console/secure_connections/building.svg.tsx b/webapp/channels/src/components/admin_console/secure_connections/building.svg.tsx index 4512f28717c..ba3b26b6b2b 100644 --- a/webapp/channels/src/components/admin_console/secure_connections/building.svg.tsx +++ b/webapp/channels/src/components/admin_console/secure_connections/building.svg.tsx @@ -6,285 +6,411 @@ import React from 'react'; function BuildingSvg() { return ( - + + + + + + + + + + + + + + - - - + + - - - - - - - - - - - - - - - - - + + - - + + + + + + + + + + + + + + + + + + - ); } diff --git a/webapp/channels/src/components/admin_console/secure_connections/chat.svg.tsx b/webapp/channels/src/components/admin_console/secure_connections/chat.svg.tsx index f8a46aeded2..bcc74c62b9a 100644 --- a/webapp/channels/src/components/admin_console/secure_connections/chat.svg.tsx +++ b/webapp/channels/src/components/admin_console/secure_connections/chat.svg.tsx @@ -6,44 +6,146 @@ import React from 'react'; function ChatSvg() { return ( - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/webapp/channels/src/components/admin_console/secure_connections/controls.tsx b/webapp/channels/src/components/admin_console/secure_connections/controls.tsx index 693308db710..26cd67fc469 100644 --- a/webapp/channels/src/components/admin_console/secure_connections/controls.tsx +++ b/webapp/channels/src/components/admin_console/secure_connections/controls.tsx @@ -63,9 +63,11 @@ export const AdminSection = styled.section.attrs({className: 'AdminPanel'})` export const PlaceholderHeading = styled.h4` && { font-size: 20px; + font-family: 'Metropolis', sans-serif; font-weight: 600; line-height: 28px; margin-bottom: 4px; + margin-top: 0; } `; @@ -88,14 +90,15 @@ export const PlaceholderContainer = styled.div` display: flex; place-items: center; flex-direction: column; - gap: 5px; - - svg { - margin: 30px 30px 20px; - } + gap: 24px; hgroup { text-align: center; + + p { + color: rgba(var(--center-channel-color-rgb), 0.75); + margin-bottom: 0; + } } `; diff --git a/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_accept_invite_modal.tsx b/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_accept_invite_modal.tsx index 00fb91c12d6..77ee3804f4b 100644 --- a/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_accept_invite_modal.tsx +++ b/webapp/channels/src/components/admin_console/secure_connections/modals/secure_connection_accept_invite_modal.tsx @@ -17,8 +17,6 @@ import TeamSelector from '../team_selector'; import {isErrorState, isPendingState, useTeamOptions} from '../utils'; type Props = { - creating?: boolean; - password?: string; onConfirm: (accept: PartialExcept) => Promise; onCancel?: () => void; onExited: () => void; diff --git a/webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_add_modal.tsx b/webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_add_modal.tsx index 503727db1cc..c525530355f 100644 --- a/webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_add_modal.tsx +++ b/webapp/channels/src/components/admin_console/secure_connections/modals/shared_channels_add_modal.tsx @@ -7,7 +7,6 @@ import {FormattedMessage, useIntl} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; import styled from 'styled-components'; -import {ArchiveOutlineIcon, GlobeIcon, LockIcon} from '@mattermost/compass-icons/components'; import type IconProps from '@mattermost/compass-icons/components/props'; import {GenericModal} from '@mattermost/components'; import type {Channel, ChannelWithTeamData} from '@mattermost/types/channels'; @@ -19,7 +18,7 @@ import {getChannel} from 'mattermost-redux/selectors/entities/channels'; import SectionNotice from 'components/section_notice'; import ChannelsInput from 'components/widgets/inputs/channels_input'; -import {isArchivedChannel} from 'utils/channel_utils'; +import {getChannelIconComponent} from 'utils/channel_utils'; import Constants from 'utils/constants'; import type {GlobalState} from 'types/store'; @@ -269,15 +268,7 @@ const ChannelLabel = ({channel, bold}: {channel: Channel; bold?: boolean}) => { }; const ChannelIcon = ({channel, size = 16, ...otherProps}: {channel: Channel} & IconProps) => { - let Icon = GlobeIcon; - - if (channel?.type === Constants.PRIVATE_CHANNEL) { - Icon = LockIcon; - } - - if (isArchivedChannel(channel)) { - Icon = ArchiveOutlineIcon; - } + const Icon = getChannelIconComponent(channel); return ( { const channel = useSelector((state: GlobalState) => getChannel(state, channelId)); - let icon = ; - - if (channel?.type === Constants.PRIVATE_CHANNEL) { - icon = ; - } - - if (isArchivedChannel(channel)) { - icon = ; - } + const IconComponent = getChannelIconComponent(channel); return ( - {icon} + ); }; diff --git a/webapp/channels/src/components/admin_console/server_logs/log_list.tsx b/webapp/channels/src/components/admin_console/server_logs/log_list.tsx index 957910e80b3..266943e4795 100644 --- a/webapp/channels/src/components/admin_console/server_logs/log_list.tsx +++ b/webapp/channels/src/components/admin_console/server_logs/log_list.tsx @@ -22,7 +22,6 @@ type Props = { onFiltersChange: (filters: LogFilter) => void; onSearchChange: (term: string) => void; search: string; - filters: LogFilter; }; type State = { @@ -366,7 +365,6 @@ export default class LogList extends React.PureComponent { term={search} placeholderEmpty={placeholderEmpty} rowsContainerStyles={rowsContainerStyles} - page={this.state.page} nextPage={this.nextPage} previousPage={this.previousPage} filterProps={filterProps} diff --git a/webapp/channels/src/components/admin_console/server_logs/logs.tsx b/webapp/channels/src/components/admin_console/server_logs/logs.tsx index 8512f392e31..b1dafa76bfe 100644 --- a/webapp/channels/src/components/admin_console/server_logs/logs.tsx +++ b/webapp/channels/src/components/admin_console/server_logs/logs.tsx @@ -172,12 +172,6 @@ export default class Logs extends React.PureComponent { onSearchChange={this.onSearchChange} search={this.state.search} onFiltersChange={this.onFiltersChange} - filters={{ - dateFrom: this.state.dateFrom, - dateTo: this.state.dateTo, - logLevels: this.state.logLevels, - serverNames: this.state.serverNames, - }} /> ); diff --git a/webapp/channels/src/components/admin_console/session_length_settings.tsx b/webapp/channels/src/components/admin_console/session_length_settings.tsx index dd27c2c509b..d5184694afd 100644 --- a/webapp/channels/src/components/admin_console/session_length_settings.tsx +++ b/webapp/channels/src/components/admin_console/session_length_settings.tsx @@ -39,14 +39,14 @@ const messages = defineMessages({ extendSessionLengthActivity_label: {id: 'admin.service.extendSessionLengthActivity.label', defaultMessage: 'Extend session length with activity: '}, extendSessionLengthActivity_helpText: {id: 'admin.service.extendSessionLengthActivity.helpText', defaultMessage: 'When true, sessions will be automatically extended when the user is active in their Mattermost client. Users sessions will only expire if they are not active in their Mattermost client for the entire duration of the session lengths defined in the fields below. When false, sessions will not extend with activity in Mattermost. User sessions will immediately expire at the end of the session length or idle timeouts defined below. '}, terminateSessionsOnPasswordChange_label: {id: 'admin.service.terminateSessionsOnPasswordChange.label', defaultMessage: 'Terminate Sessions on Password Change: '}, - terminateSessionsOnPasswordChange_helpText: {id: 'admin.service.terminateSessionsOnPasswordChange.helpText', defaultMessage: 'When true, all sessions of a user will expire if their password is changed by themselves or an administrator.'}, + terminateSessionsOnPasswordChange_helpText: {id: 'admin.service.terminateSessionsOnPasswordChange.helpText', defaultMessage: 'When true, all sessions of a user will expire if their password is changed by themselves or an administrator. If password change is initiated by user, their current session is not terminated.'}, webSessionHours: {id: 'admin.service.webSessionHours', defaultMessage: 'Session Length AD/LDAP and Email (hours):'}, mobileSessionHours: {id: 'admin.service.mobileSessionHours', defaultMessage: 'Session Length Mobile (hours):'}, ssoSessionHours: {id: 'admin.service.ssoSessionHours', defaultMessage: 'Session Length SSO (hours):'}, sessionCache: {id: 'admin.service.sessionCache', defaultMessage: 'Session Cache (minutes):'}, sessionCacheDesc: {id: 'admin.service.sessionCacheDesc', defaultMessage: 'The number of minutes to cache a session in memory:'}, sessionHoursEx: {id: 'admin.service.sessionHoursEx', defaultMessage: 'E.g.: "720"'}, - sessionIdleTimeoutDesc: {id: 'admin.service.sessionIdleTimeoutDesc', defaultMessage: "The number of minutes from the last time a user was active on the system to the expiry of the user's session. Once expired, the user will need to log in to continue. Minimum is 5 minutes, and 0 is unlimited. Applies to the desktop app and browsers. For mobile apps, use an EMM provider to lock the app when not in use. In High Availability mode, enable IP hash load balancing for reliable timeout measurement."}, + sessionIdleTimeoutDesc: {id: 'admin.service.sessionIdleTimeoutDesc', defaultMessage: "The number of minutes from the last time a user was active on the system to the expiry of the user's session. Once expired, the user will need to log in to continue. Minimum is 5 minutes, and 0 is unlimited.\n \nApplies to the desktop app and browsers. For mobile apps, use an EMM provider to lock the app when not in use. In High Availability mode, enable IP hash load balancing for reliable timeout measurement."}, // eslint-disable-line formatjs/no-multiple-whitespaces }); export const searchableStrings = [ diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_table.tsx b/webapp/channels/src/components/admin_console/system_properties/user_properties_table.tsx index 4811652c5fb..4de7dea8742 100644 --- a/webapp/channels/src/components/admin_console/system_properties/user_properties_table.tsx +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_table.tsx @@ -156,7 +156,6 @@ export function UserPropertiesTable({ value={getValue()} label={formatMessage({id: 'admin.system_properties.user_properties.table.property_name.input.name', defaultMessage: 'Attribute Name'})} deleted={toDelete} - borderless={!warning} testid='property-field-input' autoFocus={isCreatePending(row.original) && !supportsOptions(row.original)} setValue={(value: string) => { @@ -351,7 +350,6 @@ type EditCellProps = { footer?: ReactNode; strong?: boolean; maxLength?: number; - borderless?: boolean; }; const EditCell = (props: EditCellProps) => { const [value, setValue] = useState(props.value); diff --git a/webapp/channels/src/components/admin_console/system_roles/__snapshots__/system_roles.test.tsx.snap b/webapp/channels/src/components/admin_console/system_roles/__snapshots__/system_roles.test.tsx.snap index f4225481ea1..5af54c20656 100644 --- a/webapp/channels/src/components/admin_console/system_roles/__snapshots__/system_roles.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/system_roles/__snapshots__/system_roles.test.tsx.snap @@ -63,7 +63,6 @@ exports[`admin_console/system_roles should match snapshot 1`] = ` endCount={1} loading={false} nextPage={[Function]} - page={1} previousPage={[Function]} rows={ Array [ diff --git a/webapp/channels/src/components/admin_console/system_roles/system_role/add_users_to_role_modal/__snapshots__/add_users_to_role_modal.test.tsx.snap b/webapp/channels/src/components/admin_console/system_roles/system_role/add_users_to_role_modal/__snapshots__/add_users_to_role_modal.test.tsx.snap index 0260f7827f8..a8546e3677f 100644 --- a/webapp/channels/src/components/admin_console/system_roles/system_role/add_users_to_role_modal/__snapshots__/add_users_to_role_modal.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/system_roles/system_role/add_users_to_role_modal/__snapshots__/add_users_to_role_modal.test.tsx.snap @@ -180,7 +180,7 @@ exports[`admin_console/add_users_to_role_modal search should not include bot use perPage={50} placeholderText={ Object { - "defaultMessage": "Search and add members", + "defaultMessage": "Search for people", "id": "multiselect.placeholder", } } @@ -332,7 +332,7 @@ exports[`admin_console/add_users_to_role_modal should exclude user 1`] = ` perPage={50} placeholderText={ Object { - "defaultMessage": "Search and add members", + "defaultMessage": "Search for people", "id": "multiselect.placeholder", } } @@ -528,7 +528,7 @@ exports[`admin_console/add_users_to_role_modal should have single passed value 1 perPage={50} placeholderText={ Object { - "defaultMessage": "Search and add members", + "defaultMessage": "Search for people", "id": "multiselect.placeholder", } } @@ -765,7 +765,7 @@ exports[`admin_console/add_users_to_role_modal should include additional user 1` perPage={50} placeholderText={ Object { - "defaultMessage": "Search and add members", + "defaultMessage": "Search for people", "id": "multiselect.placeholder", } } @@ -1002,7 +1002,7 @@ exports[`admin_console/add_users_to_role_modal should include additional user 2` perPage={50} placeholderText={ Object { - "defaultMessage": "Search and add members", + "defaultMessage": "Search for people", "id": "multiselect.placeholder", } } @@ -1198,7 +1198,7 @@ exports[`admin_console/add_users_to_role_modal should not include bot user 1`] = perPage={50} placeholderText={ Object { - "defaultMessage": "Search and add members", + "defaultMessage": "Search for people", "id": "multiselect.placeholder", } } diff --git a/webapp/channels/src/components/admin_console/system_roles/system_role/add_users_to_role_modal/add_users_to_role_modal.tsx b/webapp/channels/src/components/admin_console/system_roles/system_role/add_users_to_role_modal/add_users_to_role_modal.tsx index c8d47a6aa27..a28edf8170a 100644 --- a/webapp/channels/src/components/admin_console/system_roles/system_role/add_users_to_role_modal/add_users_to_role_modal.tsx +++ b/webapp/channels/src/components/admin_console/system_roles/system_role/add_users_to_role_modal/add_users_to_role_modal.tsx @@ -271,7 +271,7 @@ export class AddUsersToRoleModal extends React.PureComponent { buttonSubmitLoadingText={buttonSubmitLoadingText} saving={this.state.saving} loading={this.state.loading} - placeholderText={defineMessage({id: 'multiselect.placeholder', defaultMessage: 'Search and add members'})} + placeholderText={defineMessage({id: 'multiselect.placeholder', defaultMessage: 'Search for people'})} /> diff --git a/webapp/channels/src/components/admin_console/system_roles/system_role/system_role_users/__snapshots__/system_role_users.test.tsx.snap b/webapp/channels/src/components/admin_console/system_roles/system_role/system_role_users/__snapshots__/system_role_users.test.tsx.snap index e1fbc575915..151465c7096 100644 --- a/webapp/channels/src/components/admin_console/system_roles/system_role/system_role_users/__snapshots__/system_role_users.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/system_roles/system_role/system_role_users/__snapshots__/system_role_users.test.tsx.snap @@ -167,7 +167,6 @@ exports[`admin_console/system_role_users should match snapshot 1`] = ` loading={true} nextPage={[Function]} onSearch={[Function]} - page={0} previousPage={[Function]} rows={ Array [ @@ -535,7 +534,6 @@ exports[`admin_console/system_role_users should match snapshot with readOnly tru loading={true} nextPage={[Function]} onSearch={[Function]} - page={0} previousPage={[Function]} rows={ Array [ diff --git a/webapp/channels/src/components/admin_console/system_roles/system_role/system_role_users/system_role_users.tsx b/webapp/channels/src/components/admin_console/system_roles/system_role/system_role_users/system_role_users.tsx index d51cff78147..8e828ffb879 100644 --- a/webapp/channels/src/components/admin_console/system_roles/system_role/system_role_users/system_role_users.tsx +++ b/webapp/channels/src/components/admin_console/system_roles/system_role/system_role_users/system_role_users.tsx @@ -236,7 +236,7 @@ export default class SystemRoleUsers extends React.PureComponent { }; render() { - const {page, loading} = this.state; + const {loading} = this.state; const {term, role, usersToAdd, usersToRemove, readOnly} = this.props; const {startCount, endCount, total} = this.getPaginationProps(); return ( @@ -272,7 +272,6 @@ export default class SystemRoleUsers extends React.PureComponent { columns={this.getColumns()} nextPage={this.nextPage} previousPage={this.previousPage} - page={page} startCount={startCount} endCount={endCount} loading={loading} diff --git a/webapp/channels/src/components/admin_console/system_roles/system_roles.tsx b/webapp/channels/src/components/admin_console/system_roles/system_roles.tsx index ceedfddafa2..abecce23fed 100644 --- a/webapp/channels/src/components/admin_console/system_roles/system_roles.tsx +++ b/webapp/channels/src/components/admin_console/system_roles/system_roles.tsx @@ -103,7 +103,6 @@ const SystemRoles = ({roles}: Props) => { { { message={

{ void; doRemoveUserFromTeam: (teamId: string) => Promise; doMakeUserTeamAdmin: (teamId: string) => Promise; doMakeUserTeamMember: (teamId: string) => Promise; @@ -30,7 +29,7 @@ export default class TeamRow extends React.PureComponent { return ( ); } @@ -38,14 +37,14 @@ export default class TeamRow extends React.PureComponent { return ( ); } return ( ); }; diff --git a/webapp/channels/src/components/admin_console/system_users/system_users.scss b/webapp/channels/src/components/admin_console/system_users/system_users.scss index 1d91a57a0ad..ee176a9da81 100644 --- a/webapp/channels/src/components/admin_console/system_users/system_users.scss +++ b/webapp/channels/src/components/admin_console/system_users/system_users.scss @@ -80,11 +80,3 @@ table.systemUsersTable { } } } - -.systemUsers__mySqlAlertBanner { - margin-bottom: 20px; - - .systemUsers__mySqlAlertBanner-buttons { - margin-top: 12px; - } -} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_export/upgrade_export_data_modal.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_export/upgrade_export_data_modal.tsx index 4fb266b2157..cea2747803e 100644 --- a/webapp/channels/src/components/admin_console/system_users/system_users_export/upgrade_export_data_modal.tsx +++ b/webapp/channels/src/components/admin_console/system_users/system_users_export/upgrade_export_data_modal.tsx @@ -5,7 +5,7 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal'; -import SetupSystemSvg from 'components/common/svg_images_components/setup_system'; +import SetupSystemSvg from 'components/common/svg_images_components/setup_system_svg'; import ConfirmModalRedux from 'components/confirm_modal_redux'; import './upgrade_export_data_modal.scss'; diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_list_actions/deactivate_member_modal.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_list_actions/deactivate_member_modal.tsx index 64d4059bc74..4907f9e6726 100644 --- a/webapp/channels/src/components/admin_console/system_users/system_users_list_actions/deactivate_member_modal.tsx +++ b/webapp/channels/src/components/admin_console/system_users/system_users_list_actions/deactivate_member_modal.tsx @@ -52,7 +52,7 @@ export default function DeactivateMemberModal({user, onExited, onSuccess, onErro const defaultMessage = ( ( } onClick={handleRemoveSessionsClick} diff --git a/webapp/channels/src/components/admin_console/system_users/utils/index.tsx b/webapp/channels/src/components/admin_console/system_users/utils/index.tsx index 829c754c669..0ed1215f53e 100644 --- a/webapp/channels/src/components/admin_console/system_users/utils/index.tsx +++ b/webapp/channels/src/components/admin_console/system_users/utils/index.tsx @@ -164,7 +164,7 @@ export function getDefaultSelectedTeam(teamId: Team['id'] | string, label?: stri label: ( ), }; @@ -174,7 +174,7 @@ export function getDefaultSelectedTeam(teamId: Team['id'] | string, label?: stri label: ( ), }; diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_access_control_policy.tsx b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_access_control_policy.tsx index 6bfbeebdd8e..0c477d644d6 100644 --- a/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_access_control_policy.tsx +++ b/webapp/channels/src/components/admin_console/team_channel_settings/channel/details/channel_access_control_policy.tsx @@ -135,7 +135,7 @@ export const ChannelAccessControl: React.FC = (props: Props): JSX.Element return ( { diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/__snapshots__/channel_list.test.tsx.snap b/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/__snapshots__/channel_list.test.tsx.snap index 00b799854ff..d2813cc081a 100644 --- a/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/__snapshots__/channel_list.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/__snapshots__/channel_list.test.tsx.snap @@ -140,7 +140,6 @@ exports[`admin_console/team_channel_settings/channel/ChannelList should match sn loading={false} nextPage={[Function]} onSearch={[Function]} - page={0} placeholderEmpty={
`; + +exports[`admin_console/team_channel_settings/channel/ChannelList should render correct icon for archived private channel 1`] = ` +
+ , + "width": 4, + }, + Object { + "field": "team", + "fixed": true, + "name": , + "width": 1.5, + }, + Object { + "field": "management", + "fixed": true, + "name": , + }, + Object { + "field": "edit", + "fixed": true, + "name": "", + "textAlign": "right", + }, + ] + } + endCount={1} + filterProps={ + Object { + "keys": Array [ + "teams", + "channels", + "management", + ], + "onFilter": [Function], + "options": Object { + "channels": Object { + "keys": Array [ + "public", + "private", + "deleted", + ], + "name": "Channels", + "values": Object { + "deleted": Object { + "name": , + "value": false, + }, + "private": Object { + "name": , + "value": false, + }, + "public": Object { + "name": , + "value": false, + }, + }, + }, + "management": Object { + "keys": Array [ + "group_constrained", + "exclude_group_constrained", + "access_control_policy_enforced", + ], + "name": "Management", + "values": Object { + "access_control_policy_enforced": Object { + "name": , + "value": false, + }, + "exclude_group_constrained": Object { + "name": , + "value": false, + }, + "group_constrained": Object { + "name": , + "value": false, + }, + }, + }, + "teams": Object { + "keys": Array [ + "team_ids", + ], + "name": "Teams", + "type": Object { + "$$typeof": Symbol(react.memo), + "WrappedComponent": [Function], + "compare": null, + "type": [Function], + }, + "values": Object { + "team_ids": Object { + "name": , + "value": Array [], + }, + }, + }, + }, + } + } + loading={false} + nextPage={[Function]} + onSearch={[Function]} + placeholderEmpty={ + + } + previousPage={[Function]} + rows={ + Array [ + Object { + "cells": Object { + "edit": + + + + , + "id": "archived-private", + "management": + + + + , + "name": + + + Archived Private + + , + "team": + teamDisplayName + , + }, + "onClick": [Function], + }, + ] + } + rowsContainerStyles={ + Object { + "minHeight": "40px", + } + } + startCount={1} + term="" + total={1} + /> +
+`; + +exports[`admin_console/team_channel_settings/channel/ChannelList should render correct icon for archived public channel 1`] = ` +
+ , + "width": 4, + }, + Object { + "field": "team", + "fixed": true, + "name": , + "width": 1.5, + }, + Object { + "field": "management", + "fixed": true, + "name": , + }, + Object { + "field": "edit", + "fixed": true, + "name": "", + "textAlign": "right", + }, + ] + } + endCount={1} + filterProps={ + Object { + "keys": Array [ + "teams", + "channels", + "management", + ], + "onFilter": [Function], + "options": Object { + "channels": Object { + "keys": Array [ + "public", + "private", + "deleted", + ], + "name": "Channels", + "values": Object { + "deleted": Object { + "name": , + "value": false, + }, + "private": Object { + "name": , + "value": false, + }, + "public": Object { + "name": , + "value": false, + }, + }, + }, + "management": Object { + "keys": Array [ + "group_constrained", + "exclude_group_constrained", + "access_control_policy_enforced", + ], + "name": "Management", + "values": Object { + "access_control_policy_enforced": Object { + "name": , + "value": false, + }, + "exclude_group_constrained": Object { + "name": , + "value": false, + }, + "group_constrained": Object { + "name": , + "value": false, + }, + }, + }, + "teams": Object { + "keys": Array [ + "team_ids", + ], + "name": "Teams", + "type": Object { + "$$typeof": Symbol(react.memo), + "WrappedComponent": [Function], + "compare": null, + "type": [Function], + }, + "values": Object { + "team_ids": Object { + "name": , + "value": Array [], + }, + }, + }, + }, + } + } + loading={false} + nextPage={[Function]} + onSearch={[Function]} + placeholderEmpty={ + + } + previousPage={[Function]} + rows={ + Array [ + Object { + "cells": Object { + "edit": + + + + , + "id": "archived-public", + "management": + + + + , + "name": + + + Archived Public + + , + "team": + teamDisplayName + , + }, + "onClick": [Function], + }, + ] + } + rowsContainerStyles={ + Object { + "minHeight": "40px", + } + } + startCount={1} + term="" + total={1} + /> +
+`; diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/channel_list.test.tsx b/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/channel_list.test.tsx index 8a7752ca56c..66bead1e418 100644 --- a/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/channel_list.test.tsx +++ b/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/channel_list.test.tsx @@ -4,7 +4,9 @@ import {shallow} from 'enzyme'; import React from 'react'; -import type {Channel} from '@mattermost/types/channels'; +import type {Channel, ChannelWithTeamData} from '@mattermost/types/channels'; + +import {General} from 'mattermost-redux/constants'; import {TestHelper} from 'utils/test_helper'; @@ -92,4 +94,60 @@ describe('admin_console/team_channel_settings/channel/ChannelList', () => { wrapper.setState({loading: false}); expect(wrapper).toMatchSnapshot(); }); + + test('should render correct icon for archived public channel', () => { + const archivedPublicChannel: ChannelWithTeamData[] = [{ + ...channel, + id: 'archived-public', + type: General.OPEN_CHANNEL, + display_name: 'Archived Public', + delete_at: 1234567890, + team_display_name: 'teamDisplayName', + team_name: 'teamName', + team_update_at: 1, + }]; + + const actions = { + getData: jest.fn().mockResolvedValue(archivedPublicChannel), + searchAllChannels: jest.fn().mockResolvedValue(archivedPublicChannel), + }; + + const wrapper = shallow( + ); + + wrapper.setState({loading: false}); + expect(wrapper).toMatchSnapshot(); + }); + + test('should render correct icon for archived private channel', () => { + const archivedPrivateChannel: ChannelWithTeamData[] = [{ + ...channel, + id: 'archived-private', + type: General.PRIVATE_CHANNEL, + display_name: 'Archived Private', + delete_at: 1234567890, + team_display_name: 'teamDisplayName', + team_name: 'teamName', + team_update_at: 1, + }]; + + const actions = { + getData: jest.fn().mockResolvedValue(archivedPrivateChannel), + searchAllChannels: jest.fn().mockResolvedValue(archivedPrivateChannel), + }; + + const wrapper = shallow( + ); + + wrapper.setState({loading: false}); + expect(wrapper).toMatchSnapshot(); + }); }); diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/channel_list.tsx b/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/channel_list.tsx index 3d690a9ba3f..75c35004f24 100644 --- a/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/channel_list.tsx +++ b/webapp/channels/src/components/admin_console/team_channel_settings/channel/list/channel_list.tsx @@ -16,13 +16,9 @@ import type {FilterOptions} from 'components/admin_console/filter/filter'; import TeamFilterDropdown from 'components/admin_console/filter/team_filter_dropdown'; import {PAGE_SIZE} from 'components/admin_console/team_channel_settings/abstract_list'; import SharedChannelIndicator from 'components/shared_channel_indicator'; -import ArchiveIcon from 'components/widgets/icons/archive_icon'; -import GlobeIcon from 'components/widgets/icons/globe_icon'; -import LockIcon from 'components/widgets/icons/lock_icon'; import {getHistory} from 'utils/browser_history'; -import {isArchivedChannel} from 'utils/channel_utils'; -import {Constants} from 'utils/constants'; +import {getChannelIconComponent} from 'utils/channel_utils'; import './channel_list.scss'; @@ -190,20 +186,13 @@ export default class ChannelList extends React.PureComponent { - let iconToDisplay = ; - - if (channel.type === Constants.PRIVATE_CHANNEL) { - iconToDisplay = ; - } - - if (isArchivedChannel(channel)) { - iconToDisplay = ( - - ); - } + const ChannelIconComponent = getChannelIconComponent(channel); + const iconToDisplay = ( + + ); const sharedChannelIcon = channel.shared ? ( ); diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/group/group_users/__snapshots__/users_to_remove.test.tsx.snap b/webapp/channels/src/components/admin_console/team_channel_settings/group/group_users/__snapshots__/users_to_remove.test.tsx.snap index a302d4493ea..c7f641addcd 100644 --- a/webapp/channels/src/components/admin_console/team_channel_settings/group/group_users/__snapshots__/users_to_remove.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/team_channel_settings/group/group_users/__snapshots__/users_to_remove.test.tsx.snap @@ -89,7 +89,6 @@ exports[`components/admin_console/team_channel_settings/group/UsersToRemove shou loading={true} nextPage={[Function]} onSearch={[Function]} - page={0} placeholderEmpty={ `; -exports[`components/admin_console/team_channel_settings/group/UsersToRemove should match snapshot searching with filters 1`] = ` -
- , - "width": 5, - }, - Object { - "field": "role", - "name": , - "width": 2, - }, - Object { - "field": "groups", - "name": , - "width": 3, - }, - ] - } - endCount={2} - filterProps={ - Object { - "keys": Array [ - "role", - ], - "onFilter": [Function], - "options": Object { - "role": Object { - "keys": Array [ - "system_guest", - "team_user", - "team_admin", - "system_admin", - ], - "name": , - "values": Object { - "system_admin": Object { - "name": , - "value": false, - }, - "system_guest": Object { - "name": , - "value": false, - }, - "team_admin": Object { - "name": , - "value": false, - }, - "team_user": Object { - "name": , - "value": false, - }, - }, - }, - }, - } - } - loading={true} - nextPage={[Function]} - onSearch={[Function]} - page={0} - placeholderEmpty={ - - } - previousPage={[Function]} - rows={Array []} - startCount={1} - term="foo" - total={2} - /> -
-`; - exports[`components/admin_console/team_channel_settings/group/UsersToRemove should match snapshot with 2 users 1`] = `
{ - const wrapper = shallow( - , - ); - expect(wrapper).toMatchSnapshot(); - }); - test('should match snapshot loading', () => { const wrapper = shallow( Promise; loadChannelMembersForProfilesList: (profiles: UserProfile[], channelId: string) => Promise; @@ -345,7 +344,6 @@ export default class UsersToRemove extends React.PureComponent { columns={columns} rows={rows} loading={this.state.loading} - page={this.state.page} nextPage={this.nextPage} previousPage={this.previousPage} startCount={startCount} diff --git a/webapp/channels/src/components/admin_console/team_channel_settings/team/list/team_list.tsx b/webapp/channels/src/components/admin_console/team_channel_settings/team/list/team_list.tsx index 69508f3fb86..ae8c4864f85 100644 --- a/webapp/channels/src/components/admin_console/team_channel_settings/team/list/team_list.tsx +++ b/webapp/channels/src/components/admin_console/team_channel_settings/team/list/team_list.tsx @@ -386,7 +386,6 @@ export default class TeamList extends React.PureComponent { columns={columns} rows={rows} loading={this.state.loading} - page={this.state.page} nextPage={this.nextPage} previousPage={this.previousPage} startCount={startCount} diff --git a/webapp/channels/src/components/admin_console/user_grid/__snapshots__/user_grid.test.tsx.snap b/webapp/channels/src/components/admin_console/user_grid/__snapshots__/user_grid.test.tsx.snap index 8abba30c24c..a9b76d767de 100644 --- a/webapp/channels/src/components/admin_console/user_grid/__snapshots__/user_grid.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/user_grid/__snapshots__/user_grid.test.tsx.snap @@ -45,7 +45,6 @@ exports[`components/admin_console/user_grid/UserGrid should match snapshot with loading={false} nextPage={[Function]} onSearch={[Function]} - page={0} placeholderEmpty={ { columns={columns} rows={rows} loading={this.state.loading || this.props.loading} - page={this.state.page} nextPage={this.nextPage} previousPage={this.previousPage} startCount={startCount} diff --git a/webapp/channels/src/components/advanced_create_post/prewritten_chips.tsx b/webapp/channels/src/components/advanced_create_post/prewritten_chips.tsx index 2cbfdb9823e..d2f6e932d05 100644 --- a/webapp/channels/src/components/advanced_create_post/prewritten_chips.tsx +++ b/webapp/channels/src/components/advanced_create_post/prewritten_chips.tsx @@ -132,6 +132,7 @@ const PrewrittenChips = ({channelId, currentUserId, prefillMessage}: Props) => { event: 'prefilled_message_selected_dm_hey', message: defineMessage({ id: 'create_post.prewritten.tip.dm_hey_message', + // eslint-disable-next-line formatjs/enforce-placeholders -- username provided when message is formatted defaultMessage: ':wave: Hey @{username}', }), display: defineMessage({ diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx index 6c8f9076833..f9dfbd0685a 100644 --- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx +++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx @@ -819,7 +819,6 @@ const AdvancedTextEditor = ({ useChannelMentions={useChannelMentions} rootId={rootId} onWidthChange={handleWidthChange} - isInEditMode={isInEditMode} /> {attachmentPreview} {!isDisabled && (showFormattingBar || showPreview) && ( diff --git a/webapp/channels/src/components/advanced_text_editor/priority_labels/index.tsx b/webapp/channels/src/components/advanced_text_editor/priority_labels/index.tsx index e8d9f5df4f6..1e3ec2d3dbb 100644 --- a/webapp/channels/src/components/advanced_text_editor/priority_labels/index.tsx +++ b/webapp/channels/src/components/advanced_text_editor/priority_labels/index.tsx @@ -79,7 +79,7 @@ function PriorityLabels({
} @@ -79,14 +73,8 @@ exports[`components/ConfigurationBar should match snapshot, expired, in grace pe } /> -
} diff --git a/webapp/channels/src/components/announcement_bar/configuration_bar/configuration_bar.tsx b/webapp/channels/src/components/announcement_bar/configuration_bar/configuration_bar.tsx index e34adc32f5f..f555dc909a6 100644 --- a/webapp/channels/src/components/announcement_bar/configuration_bar/configuration_bar.tsx +++ b/webapp/channels/src/components/announcement_bar/configuration_bar/configuration_bar.tsx @@ -7,7 +7,7 @@ import {FormattedMessage, injectIntl} from 'react-intl'; import type {IntlShape} from 'react-intl'; import {Link} from 'react-router-dom'; -import type {ClientConfig, WarnMetricStatus} from '@mattermost/types/config'; +import type {ClientConfig} from '@mattermost/types/config'; import type {PreferenceType} from '@mattermost/types/preferences'; import type {ActionResult} from 'mattermost-redux/types/actions'; @@ -34,13 +34,8 @@ type Props = { dismissedExpiringTrialLicense?: boolean; dismissedExpiringLicense?: boolean; dismissedExpiredLicense?: boolean; - dismissedNumberOfActiveUsersWarnMetricStatus?: boolean; - dismissedNumberOfActiveUsersWarnMetricStatusAck?: boolean; - dismissedNumberOfPostsWarnMetricStatus?: boolean; - dismissedNumberOfPostsWarnMetricStatusAck?: boolean; siteURL: string; currentUserId: string; - warnMetricsStatus?: Record; actions: { dismissNotice: (notice: string) => void; savePreferences: (userId: string, preferences: PreferenceType[]) => Promise; @@ -67,8 +62,6 @@ const ConfigurationAnnouncementBar = (props: Props) => { props.actions.dismissNotice(AnnouncementBarMessages.TRIAL_LICENSE_EXPIRING); }; - const renewLinkTelemetry = {success: 'renew_license_banner_success', error: 'renew_license_banner_fail'}; - // System administrators if (props.canViewSystemErrors) { if ((isLicensePastGracePeriod(props.license) || isLicenseExpired(props.license)) && !props.dismissedExpiredLicense) { @@ -93,7 +86,6 @@ const ConfigurationAnnouncementBar = (props: Props) => { {message}
} @@ -199,7 +191,6 @@ const ConfigurationAnnouncementBar = (props: Props) => { {message}
} @@ -220,7 +211,7 @@ const ConfigurationAnnouncementBar = (props: Props) => { src={warningIcon} /> { props.config?.EnablePreviewModeBanner === 'true' ) { const emailMessage = formatMessage({ - id: AnnouncementBarMessages.PREVIEW_MODE, - defaultMessage: 'Preview Mode: Email notifications have not been configured', + id: 'announcement_bar.error.preview_mode', + defaultMessage: 'Preview Mode: Email notifications have not been configured.', }); return ( diff --git a/webapp/channels/src/components/announcement_bar/contact_sales/contact_us.tsx b/webapp/channels/src/components/announcement_bar/contact_sales/contact_us.tsx index bd0620666fb..fe45e50bd78 100644 --- a/webapp/channels/src/components/announcement_bar/contact_sales/contact_us.tsx +++ b/webapp/channels/src/components/announcement_bar/contact_sales/contact_us.tsx @@ -8,7 +8,6 @@ import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; export interface Props { buttonTextElement?: JSX.Element; - eventID?: string; customClass?: string; } diff --git a/webapp/channels/src/components/announcement_bar/notification_permission_bar/index.tsx b/webapp/channels/src/components/announcement_bar/notification_permission_bar/index.tsx index 8547a778403..7d3b723cc31 100644 --- a/webapp/channels/src/components/announcement_bar/notification_permission_bar/index.tsx +++ b/webapp/channels/src/components/announcement_bar/notification_permission_bar/index.tsx @@ -17,6 +17,7 @@ import { NotificationPermissionNeverGranted, getNotificationPermission, } from 'utils/notifications'; +import * as UserAgent from 'utils/user_agent'; export default function NotificationPermissionBar() { const isLoggedIn = Boolean(useSelector(getCurrentUserId)); @@ -34,7 +35,8 @@ export default function NotificationPermissionBar() { } // When browser does not support notification API, we show the notification bar to update browser - if (!isNotificationAPISupported()) { + // Don't show for MS 365 mobile apps (Teams, Outlook) as they intentionally don't support notifications + if (!isNotificationAPISupported() && !UserAgent.isM365Mobile()) { return ; } diff --git a/webapp/channels/src/components/announcement_bar/payment_announcement_bar/index.tsx b/webapp/channels/src/components/announcement_bar/payment_announcement_bar/index.tsx index f770a911e2c..2d7b7b00d4c 100644 --- a/webapp/channels/src/components/announcement_bar/payment_announcement_bar/index.tsx +++ b/webapp/channels/src/components/announcement_bar/payment_announcement_bar/index.tsx @@ -88,6 +88,6 @@ export default function PaymentAnnouncementBar() { const messages = defineMessages({ updatePaymentInfo: { id: 'admin.billing.subscription.updatePaymentInfo', - defaultMessage: 'Update payment info', + defaultMessage: 'Update Payment Information', }, }); diff --git a/webapp/channels/src/components/announcement_bar/renewal_link/index.ts b/webapp/channels/src/components/announcement_bar/renewal_link/index.ts index 412f3bc3609..0e3cca86902 100644 --- a/webapp/channels/src/components/announcement_bar/renewal_link/index.ts +++ b/webapp/channels/src/components/announcement_bar/renewal_link/index.ts @@ -1,23 +1,6 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; -import type {Dispatch} from 'redux'; - -import {openModal} from 'actions/views/modals'; - import RenewalLink from './renewal_link'; -function mapDispatchToProps(dispatch: Dispatch) { - return { - actions: bindActionCreators( - { - openModal, - }, - dispatch, - ), - }; -} - -export default connect(null, mapDispatchToProps)(RenewalLink); +export default RenewalLink; diff --git a/webapp/channels/src/components/announcement_bar/renewal_link/renewal_link.test.tsx b/webapp/channels/src/components/announcement_bar/renewal_link/renewal_link.test.tsx index ee18430cac9..06a2cc6242d 100644 --- a/webapp/channels/src/components/announcement_bar/renewal_link/renewal_link.test.tsx +++ b/webapp/channels/src/components/announcement_bar/renewal_link/renewal_link.test.tsx @@ -58,15 +58,9 @@ describe('components/RenewalLink', () => { jest.clearAllMocks(); }); - const props = { - actions: { - openModal: jest.fn, - }, - }; - test('should show Contact sales button', async () => { const store = mockStore(initialState); - const wrapper = mountWithIntl(); + const wrapper = mountWithIntl(); // wait for the promise to resolve and component to update await actImmediate(wrapper); diff --git a/webapp/channels/src/components/announcement_bar/renewal_link/renewal_link.tsx b/webapp/channels/src/components/announcement_bar/renewal_link/renewal_link.tsx index 7a27d359ed6..a79e71e2d70 100644 --- a/webapp/channels/src/components/announcement_bar/renewal_link/renewal_link.tsx +++ b/webapp/channels/src/components/announcement_bar/renewal_link/renewal_link.tsx @@ -6,17 +6,10 @@ import {FormattedMessage} from 'react-intl'; import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; -import type {ModalData} from 'types/actions'; - import './renew_link.scss'; export interface RenewalLinkProps { - telemetryInfo?: { success: string; error: string }; - actions: { - openModal:

(modalData: ModalData

) => void; - }; isDisabled?: boolean; - customBtnText?: JSX.Element; className?: string; } diff --git a/webapp/channels/src/components/apps_form/apps_form_component.tsx b/webapp/channels/src/components/apps_form/apps_form_component.tsx index bdf31c3b3fe..fdbc4648d77 100644 --- a/webapp/channels/src/components/apps_form/apps_form_component.tsx +++ b/webapp/channels/src/components/apps_form/apps_form_component.tsx @@ -667,7 +667,6 @@ export class AppsForm extends React.PureComponent { <> {header && ( )} diff --git a/webapp/channels/src/components/apps_form/apps_form_container.tsx b/webapp/channels/src/components/apps_form/apps_form_container.tsx index 856a3ddfe5e..b2c5430f378 100644 --- a/webapp/channels/src/components/apps_form/apps_form_container.tsx +++ b/webapp/channels/src/components/apps_form/apps_form_container.tsx @@ -56,7 +56,7 @@ class AppsFormContainer extends React.PureComponent { return {error: makeCallErrorResponse(makeErrorMsg(errMsg))}; } if (!form.submit) { - const errMsg = this.props.intl.formatMessage({id: 'apps.error.form.no_submit', defaultMessage: '`submit` is not defined'}); + const errMsg = this.props.intl.formatMessage({id: 'apps.error.form.no_submit', defaultMessage: '`submit` is not defined.'}); return {error: makeCallErrorResponse(makeErrorMsg(errMsg))}; } if (!this.props.appContext) { @@ -170,7 +170,7 @@ class AppsFormContainer extends React.PureComponent { const makeErrorMsg = (message: string) => intl.formatMessage( { id: 'apps.error.form.refresh', - defaultMessage: 'There has been an error fetching the select fields. Contact the app developer. Details: {details}', + defaultMessage: 'There has been an error updating the modal. Contact the app developer. Details: {details}', }, {details: message}, ); diff --git a/webapp/channels/src/components/apps_form/apps_form_header.tsx b/webapp/channels/src/components/apps_form/apps_form_header.tsx index 21e0b59a326..1b56c51f84a 100644 --- a/webapp/channels/src/components/apps_form/apps_form_header.tsx +++ b/webapp/channels/src/components/apps_form/apps_form_header.tsx @@ -6,7 +6,6 @@ import React from 'react'; import Markdown from 'components/markdown'; type Props = { - id?: string; value: string; }; diff --git a/webapp/channels/src/components/audit_table/audit_table.test.tsx b/webapp/channels/src/components/audit_table/audit_table.test.tsx index 0f6ba16b59d..fa075263862 100644 --- a/webapp/channels/src/components/audit_table/audit_table.test.tsx +++ b/webapp/channels/src/components/audit_table/audit_table.test.tsx @@ -1,11 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {shallow} from 'enzyme'; import React from 'react'; import AuditTable from 'components/audit_table/audit_table'; -import {shallowWithIntl} from 'tests/helpers/intl-test-helper'; import {TestHelper} from 'utils/test_helper'; describe('components/audit_table/AuditTable', () => { @@ -23,7 +23,7 @@ describe('components/audit_table/AuditTable', () => { }; test('should match snapshot with no audits', () => { - const wrapper = shallowWithIntl( + const wrapper = shallow( , ); @@ -53,7 +53,7 @@ describe('components/audit_table/AuditTable', () => { ]; const props = {...baseProps, audits}; - const wrapper = shallowWithIntl( + const wrapper = shallow( , ); diff --git a/webapp/channels/src/components/audit_table/audit_table.tsx b/webapp/channels/src/components/audit_table/audit_table.tsx index e69cc03bd72..e3504e41915 100644 --- a/webapp/channels/src/components/audit_table/audit_table.tsx +++ b/webapp/channels/src/components/audit_table/audit_table.tsx @@ -2,27 +2,23 @@ // See LICENSE.txt for license information. import React from 'react'; -import {FormattedMessage, injectIntl} from 'react-intl'; -import type {IntlShape} from 'react-intl'; +import {FormattedMessage} from 'react-intl'; import type {Audit} from '@mattermost/types/audits'; -import type {UserProfile} from '@mattermost/types/users'; import FormatAudit from './format_audit'; type Props = { - intl: IntlShape; audits: Audit[]; showUserId?: boolean; showIp?: boolean; showSession?: boolean; - currentUser: UserProfile; actions: { getMissingProfilesByIds: (userIds: string[]) => void; }; }; -export class AuditTable extends React.PureComponent { +export default class AuditTable extends React.PureComponent { componentDidMount() { const ids = this.props.audits.map((audit) => audit.user_id); this.props.actions.getMissingProfilesByIds(ids); @@ -103,5 +99,3 @@ export class AuditTable extends React.PureComponent { ); } } - -export default injectIntl(AuditTable); diff --git a/webapp/channels/src/components/audit_table/index.ts b/webapp/channels/src/components/audit_table/index.ts index 91f82fada52..af0d494c4df 100644 --- a/webapp/channels/src/components/audit_table/index.ts +++ b/webapp/channels/src/components/audit_table/index.ts @@ -6,18 +6,9 @@ import {bindActionCreators} from 'redux'; import type {Dispatch} from 'redux'; import {getMissingProfilesByIds} from 'mattermost-redux/actions/users'; -import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; - -import type {GlobalState} from 'types/store'; import AuditTable from './audit_table'; -function mapStateToProps(state: GlobalState) { - return { - currentUser: getCurrentUser(state), - }; -} - function mapDispatchToProps(dispatch: Dispatch) { return { actions: bindActionCreators({ @@ -26,4 +17,4 @@ function mapDispatchToProps(dispatch: Dispatch) { }; } -export default connect(mapStateToProps, mapDispatchToProps)(AuditTable); +export default connect(null, mapDispatchToProps)(AuditTable); diff --git a/webapp/channels/src/components/card/__snapshots__/card.test.tsx.snap b/webapp/channels/src/components/card/__snapshots__/card.test.tsx.snap index 433c8e05623..94dbe3cc91d 100644 --- a/webapp/channels/src/components/card/__snapshots__/card.test.tsx.snap +++ b/webapp/channels/src/components/card/__snapshots__/card.test.tsx.snap @@ -1,159 +1,116 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`components/card/card should match snapshot 1`] = ` - +

- -
- Header Test -
-
- +
-
- Body Test -
- + Body Test +
- +
`; exports[`components/card/card should match snapshot when expanded 1`] = ` - +
- -
- Header Test -
-
-
- +
+
-
- Body Test -
- + Body Test +
-
+
`; exports[`components/card/card should match snapshot when using header content and a button 1`] = ` -
- +
- - Hello! +
+
+
+ Custom retention policies
- } - buttonText={ - - } - onClick={[Function]} - subtitle={ - - } - title={ - - } - /> - - - Body Test - +
+ Customize how long specific teams and channels will keep messages. +
+
+ +
+
+
+ Body Test +
+
`; exports[`components/card/card should match snapshot when using header content and no button 1`] = ` -
- +
- - Hello! +
+
+
+ Custom retention policies
- } - subtitle={ - - } - title={ - - } - /> - - - Body Test - +
+ Customize how long specific teams and channels will keep messages. +
+
+
+
+
+ Body Test +
+
`; diff --git a/webapp/channels/src/components/card/card.test.tsx b/webapp/channels/src/components/card/card.test.tsx index c175ba35d09..200ac7f9462 100644 --- a/webapp/channels/src/components/card/card.test.tsx +++ b/webapp/channels/src/components/card/card.test.tsx @@ -1,10 +1,11 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {mount, shallow} from 'enzyme'; import React from 'react'; import {FormattedMessage} from 'react-intl'; +import {renderWithContext} from 'tests/react_testing_utils'; + import Card from './card'; import TitleAndButtonCardHeader from './title_and_button_card_header/title_and_button_card_header'; @@ -31,14 +32,14 @@ describe('components/card/card', () => { }; test('should match snapshot', () => { - const wrapper = mount( + const {container} = renderWithContext( {'Header Test'} {'Body Test'} , ); - expect(wrapper).toMatchSnapshot(); + expect(container).toMatchSnapshot(); }); test('should match snapshot when expanded', () => { @@ -47,14 +48,14 @@ describe('components/card/card', () => { expanded: true, }; - const wrapper = mount( + const {container} = renderWithContext( {'Header Test'} {'Body Test'} , ); - expect(wrapper).toMatchSnapshot(); + expect(container).toMatchSnapshot(); }); test('should match snapshot when using header content and no button', () => { @@ -64,7 +65,7 @@ describe('components/card/card', () => { className: 'console', }; - const wrapper = shallow( + const {container} = renderWithContext( { , ); - expect(wrapper).toMatchSnapshot(); + expect(container).toMatchSnapshot(); }); test('should match snapshot when using header content and a button', () => { @@ -96,7 +97,7 @@ describe('components/card/card', () => { , }; - const wrapper = shallow( + const {container} = renderWithContext( { , ); - expect(wrapper).toMatchSnapshot(); + expect(container).toMatchSnapshot(); }); }); diff --git a/webapp/channels/src/components/channel_bookmarks/channel_bookmarks_create_modal.tsx b/webapp/channels/src/components/channel_bookmarks/channel_bookmarks_create_modal.tsx index a1ca9dd4639..0c23f9a7507 100644 --- a/webapp/channels/src/components/channel_bookmarks/channel_bookmarks_create_modal.tsx +++ b/webapp/channels/src/components/channel_bookmarks/channel_bookmarks_create_modal.tsx @@ -367,6 +367,15 @@ function ChannelBookmarkCreateModal({ } } + let linkCustomMessage; + if (linkError) { + linkCustomMessage = {type: 'error' as const, value: linkError}; + } else if (validatedLink && !linkErrorBypass) { + linkCustomMessage = null; + } else { + linkCustomMessage = {type: 'info' as const, value: linkMessage}; + } + return ( ) : ( @@ -649,6 +658,8 @@ export const useBookmarkLinkValidation = (link: string, onValidated: (validatedL const handler = setTimeout(async () => { cancel(); if (!link) { + setError(undefined); + setSuppressed(false); return; } diff --git a/webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap b/webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap index 8d7abed969f..34471948992 100644 --- a/webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap +++ b/webapp/channels/src/components/channel_header/__snapshots__/channel_header.test.tsx.snap @@ -94,7 +94,7 @@ exports[`components/ChannelHeader should match snapshot with last active display className="last-active__text" > { ; + const ArchiveIcon = getArchiveIconComponent(channel.type); + archivedIcon = ( + + ); } let sharedIcon; diff --git a/webapp/channels/src/components/channel_header/channel_info_button.tsx b/webapp/channels/src/components/channel_header/channel_info_button.tsx index 044dfbfd2cc..6331c20d094 100644 --- a/webapp/channels/src/components/channel_header/channel_info_button.tsx +++ b/webapp/channels/src/components/channel_header/channel_info_button.tsx @@ -52,7 +52,7 @@ const ChannelInfoButton = ({channel}: Props) => { let tooltip; if (buttonActive) { - tooltip = intl.formatMessage({id: 'channel_header.closeChannelInfo', defaultMessage: 'Close info'}); + tooltip = intl.formatMessage({id: 'channel_header.closeChannelInfo', defaultMessage: 'Close Info'}); } else { tooltip = intl.formatMessage({id: 'channel_header.openChannelInfo', defaultMessage: 'View Info'}); } diff --git a/webapp/channels/src/components/channel_header_menu/menu_items/archive_channel.test.tsx b/webapp/channels/src/components/channel_header_menu/menu_items/archive_channel.test.tsx index 061b6abfb00..dc8a757ac16 100644 --- a/webapp/channels/src/components/channel_header_menu/menu_items/archive_channel.test.tsx +++ b/webapp/channels/src/components/channel_header_menu/menu_items/archive_channel.test.tsx @@ -100,4 +100,38 @@ describe('components/ChannelHeaderMenu/MenuItems/ArchiveChannel', () => { }, }); }); + + test('renders ArchiveOutlineIcon for public channel', () => { + const publicChannel = TestHelper.getChannelMock({ + id: 'public_channel', + type: 'O', + display_name: 'Public Channel', + }); + + renderWithContext( + + + , initialState, + ); + + // Check that the component renders without error + expect(screen.getByText('Archive Channel')).toBeInTheDocument(); + }); + + test('renders ArchiveLockOutlineIcon for private channel', () => { + const privateChannel = TestHelper.getChannelMock({ + id: 'private_channel', + type: 'P', + display_name: 'Private Channel', + }); + + renderWithContext( + + + , initialState, + ); + + // Check that the component renders without error + expect(screen.getByText('Archive Channel')).toBeInTheDocument(); + }); }); diff --git a/webapp/channels/src/components/channel_header_menu/menu_items/archive_channel.tsx b/webapp/channels/src/components/channel_header_menu/menu_items/archive_channel.tsx index 89d96a964fb..11f81659abd 100644 --- a/webapp/channels/src/components/channel_header_menu/menu_items/archive_channel.tsx +++ b/webapp/channels/src/components/channel_header_menu/menu_items/archive_channel.tsx @@ -5,7 +5,6 @@ import React, {memo} from 'react'; import {FormattedMessage} from 'react-intl'; import {useDispatch} from 'react-redux'; -import {ArchiveOutlineIcon} from '@mattermost/compass-icons/components'; import type {Channel} from '@mattermost/types/channels'; import {openModal} from 'actions/views/modals'; @@ -13,6 +12,7 @@ import {openModal} from 'actions/views/modals'; import DeleteChannelModal from 'components/delete_channel_modal'; import * as Menu from 'components/menu'; +import {getArchiveIconComponent} from 'utils/channel_utils'; import {ModalIdentifiers} from 'utils/constants'; type Props = { @@ -36,10 +36,12 @@ const ArchiveChannel = ({ ); }; + const ArchiveIcon = getArchiveIconComponent(channel.type); + return ( } + leadingElement={} onClick={handleArchiveChannel} labels={ - - - - + + + + + +
); }; diff --git a/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.scss b/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.scss index 682de30d98e..df3acf11195 100644 --- a/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.scss +++ b/webapp/channels/src/components/channel_invite_modal/channel_invite_modal.scss @@ -1,4 +1,4 @@ -@import 'utils/mixins'; +@use 'utils/mixins'; .channel-invite { &__wrapper { diff --git a/webapp/channels/src/components/channel_invite_modal/team_warning_banner/__snapshots__/team_warning_banner.test.tsx.snap b/webapp/channels/src/components/channel_invite_modal/team_warning_banner/__snapshots__/team_warning_banner.test.tsx.snap index f40510abaca..4814e9cc1bc 100644 --- a/webapp/channels/src/components/channel_invite_modal/team_warning_banner/__snapshots__/team_warning_banner.test.tsx.snap +++ b/webapp/channels/src/components/channel_invite_modal/team_warning_banner/__snapshots__/team_warning_banner.test.tsx.snap @@ -121,7 +121,7 @@ exports[`components/channel_invite_modal/team_warning_banner should match snapsh className="AlertBanner__footerMessage" > You can add to this channel once they are members of the Team Name Display @@ -549,7 +549,7 @@ exports[`components/channel_invite_modal/team_warning_banner should match snapsh className="AlertBanner__footerMessage" > and You can add and to this channel once they are members of the Team Name Display diff --git a/webapp/channels/src/components/channel_invite_modal/team_warning_banner/team_warning_banner.tsx b/webapp/channels/src/components/channel_invite_modal/team_warning_banner/team_warning_banner.tsx index eea1550d79a..9afc59c7d46 100644 --- a/webapp/channels/src/components/channel_invite_modal/team_warning_banner/team_warning_banner.tsx +++ b/webapp/channels/src/components/channel_invite_modal/team_warning_banner/team_warning_banner.tsx @@ -96,7 +96,7 @@ const TeamWarningBanner = (props: Props) => { formatMessage( { id: 'channel_invite.invite_team_members.guests.message', - defaultMessage: '{count, plural, =1 {{firstUser} is a guest user and needs} other {{users} are guest users and need}} to first be invited to the team before you can add them to the channel. Once they\'ve joined the team, you can add them to this channel.', + defaultMessage: "{count, plural, =1 {{firstUser} is a guest user and needs} other {{users} are guest users and need}} to first be invited to the team before you can add them to the channel. Once they've joined the team, you can add them to this channel.", }, { count: guests.length, @@ -107,7 +107,6 @@ const TeamWarningBanner = (props: Props) => { mentionName={firstName} /> ), - team: ({team?.display_name}), }, ) ); diff --git a/webapp/channels/src/components/channel_layout/center_channel/center_channel.tsx b/webapp/channels/src/components/channel_layout/center_channel/center_channel.tsx index a3f02326b0f..cb95321efcd 100644 --- a/webapp/channels/src/components/channel_layout/center_channel/center_channel.tsx +++ b/webapp/channels/src/components/channel_layout/center_channel/center_channel.tsx @@ -29,6 +29,13 @@ const Drafts = makeAsyncComponent('Drafts', lazy(() => import('components/drafts
), ); +const Recaps = makeAsyncComponent('Recaps', lazy(() => import('components/recaps')), + ( +
+ +
+ ), +); const PermalinkView = makeAsyncComponent('PermalinkView', lazy(() => import('components/permalink_view'))); const PlaybookRunner = makeAsyncComponent('PlaybookRunner', lazy(() => import('components/channel_layout/playbook_runner'))); @@ -103,6 +110,10 @@ export default class CenterChannel extends React.PureComponent { component={GlobalThreads} /> ) : null} + } handleChange={(e) => handleChange({desktop_threads: e ? 'all' : 'mention'})} @@ -262,7 +262,7 @@ export default function ChannelNotificationsModal(props: Props) { inputFieldTitle={ } inputFieldValue={settings.push_threads === 'all'} diff --git a/webapp/channels/src/components/channel_settings_modal/channel_access_rules_confirm_modal.tsx b/webapp/channels/src/components/channel_settings_modal/channel_access_rules_confirm_modal.tsx index 4b0f02f28f5..5ad4b6be98a 100644 --- a/webapp/channels/src/components/channel_settings_modal/channel_access_rules_confirm_modal.tsx +++ b/webapp/channels/src/components/channel_settings_modal/channel_access_rules_confirm_modal.tsx @@ -136,10 +136,15 @@ function ChannelAccessRulesConfirmModal({ return null; } - const modalTitle = ( + const modalTitle = willShowActivityWarning ? ( + ) : ( + ); @@ -192,10 +197,15 @@ function ChannelAccessRulesConfirmModal({ ); } - return ( + return autoSyncEnabled ? ( + ) : ( + ); })()} diff --git a/webapp/channels/src/components/channel_settings_modal/channel_activity_warning_modal.test.tsx b/webapp/channels/src/components/channel_settings_modal/channel_activity_warning_modal.test.tsx index a05ad1f5dbc..52ad2e369a6 100644 --- a/webapp/channels/src/components/channel_settings_modal/channel_activity_warning_modal.test.tsx +++ b/webapp/channels/src/components/channel_settings_modal/channel_activity_warning_modal.test.tsx @@ -27,7 +27,7 @@ describe('ChannelActivityWarningModal', () => { ); expect(screen.getByText('Exposing channel history')).toBeInTheDocument(); - expect(screen.getByText(/Everyone who gains access to this channel/)).toBeInTheDocument(); + expect(screen.getByText(/Modifying access rules may allow new users/)).toBeInTheDocument(); expect(screen.getByText(/I acknowledge this change will expose/)).toBeInTheDocument(); }); diff --git a/webapp/channels/src/components/channel_settings_modal/channel_activity_warning_modal.tsx b/webapp/channels/src/components/channel_settings_modal/channel_activity_warning_modal.tsx index ab010247b8c..82bd59cd398 100644 --- a/webapp/channels/src/components/channel_settings_modal/channel_activity_warning_modal.tsx +++ b/webapp/channels/src/components/channel_settings_modal/channel_activity_warning_modal.tsx @@ -75,7 +75,7 @@ const ChannelActivityWarningModal: React.FC = ({
diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_archive_tab.test.tsx b/webapp/channels/src/components/channel_settings_modal/channel_settings_archive_tab.test.tsx index 0b6cd021d24..0672f56d27c 100644 --- a/webapp/channels/src/components/channel_settings_modal/channel_settings_archive_tab.test.tsx +++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_archive_tab.test.tsx @@ -155,7 +155,7 @@ describe('ChannelSettingsArchiveTab', () => { // Use the within function to scope the query to just the modal content const modalBody = screen.getByTestId('archiveChannelConfirmModal').querySelector('#confirmModalBody'); expect(modalBody).toBeInTheDocument(); - expect(modalBody).toHaveTextContent(/Archiving a channel removes it from the user interface/); + expect(modalBody).toHaveTextContent(/This will archive the channel from the team/); }); it('should call deleteChannel which handles channel ID validation', async () => { diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_archive_tab.tsx b/webapp/channels/src/components/channel_settings_modal/channel_settings_archive_tab.tsx index 7c4d5ebcda3..47eea23c2c1 100644 --- a/webapp/channels/src/components/channel_settings_modal/channel_settings_archive_tab.tsx +++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_archive_tab.tsx @@ -66,7 +66,7 @@ function ChannelSettingsArchiveTab({

diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.scss b/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.scss index 512855fd0c0..d9bbed9ad93 100644 --- a/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.scss +++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_configuration_tab.scss @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -@import "utils/_animations"; +@use "utils/animations"; .ChannelSettingsModal__configurationTab { display: flex; @@ -32,7 +32,7 @@ } .channel_banner_section_body { - @include fade-in; + @include animations.fade-in; display: flex; width: 100%; diff --git a/webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.tsx b/webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.tsx index 4635b3c49ce..32aa9078c5e 100644 --- a/webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.tsx +++ b/webapp/channels/src/components/channel_settings_modal/channel_settings_info_tab.tsx @@ -394,7 +394,7 @@ function ChannelSettingsInfoTab({ onChange={handlePurposeChange} createMessage={formatMessage({ id: 'channel_settings_modal.purpose.placeholder', - defaultMessage: 'Enter a purpose for this channel', + defaultMessage: 'Enter a purpose for this channel (optional)', })} maxLength={Constants.MAX_CHANNELPURPOSE_LENGTH} preview={shouldShowPreviewPurpose} @@ -408,7 +408,7 @@ function ChannelSettingsInfoTab({ hasError={channelPurpose.length > Constants.MAX_CHANNELPURPOSE_LENGTH} errorMessage={channelPurpose.length > Constants.MAX_CHANNELPURPOSE_LENGTH ? formatMessage({ id: 'channel_settings.error_purpose_length', - defaultMessage: 'The channel purpose exceeds the maximum character limit of {maxLength} characters.', + defaultMessage: 'The text entered exceeds the character limit. The channel purpose is limited to {maxLength} characters.', }, { maxLength: Constants.MAX_CHANNELPURPOSE_LENGTH, }) : undefined @@ -426,7 +426,7 @@ function ChannelSettingsInfoTab({ onChange={handleHeaderChange} createMessage={formatMessage({ id: 'channel_settings_modal.header.placeholder', - defaultMessage: 'Enter a header description or important links', + defaultMessage: 'Enter a header for this channel', })} maxLength={HEADER_MAX_LENGTH} preview={shouldShowPreviewHeader} @@ -440,7 +440,7 @@ function ChannelSettingsInfoTab({ hasError={channelHeader.length > HEADER_MAX_LENGTH} errorMessage={channelHeader.length > HEADER_MAX_LENGTH ? formatMessage({ id: 'edit_channel_header_modal.error', - defaultMessage: 'The channel header exceeds the maximum character limit of {maxLength} characters.', + defaultMessage: 'The text entered exceeds the character limit. The channel header is limited to {maxLength} characters.', }, { maxLength: HEADER_MAX_LENGTH, }) : undefined diff --git a/webapp/channels/src/components/channel_view/channel_view.tsx b/webapp/channels/src/components/channel_view/channel_view.tsx index 1fb7e3134fa..ee32ccd4d5e 100644 --- a/webapp/channels/src/components/channel_view/channel_view.tsx +++ b/webapp/channels/src/components/channel_view/channel_view.tsx @@ -179,9 +179,6 @@ export default class ChannelView extends React.PureComponent { {chunks}, - }} /> + )} + +

+
+ ); + + return ( + +
+ {error && ( +
+ + {error} +
+ )} + {renderStep()} +
+
+ ); +}; + +export default CreateRecapModal; + diff --git a/webapp/channels/src/components/create_recap_modal/index.ts b/webapp/channels/src/components/create_recap_modal/index.ts new file mode 100644 index 00000000000..4898d8e3c3d --- /dev/null +++ b/webapp/channels/src/components/create_recap_modal/index.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export {default} from './create_recap_modal'; + diff --git a/webapp/channels/src/components/create_recap_modal/recap_configuration.test.tsx b/webapp/channels/src/components/create_recap_modal/recap_configuration.test.tsx new file mode 100644 index 00000000000..72ab574229d --- /dev/null +++ b/webapp/channels/src/components/create_recap_modal/recap_configuration.test.tsx @@ -0,0 +1,250 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import type {Channel} from '@mattermost/types/channels'; + +import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; + +import RecapConfiguration from './recap_configuration'; + +describe('RecapConfiguration', () => { + const mockUnreadChannels: Channel[] = [ + { + id: 'channel1', + name: 'channel-1', + display_name: 'Channel 1', + type: 'O', + } as Channel, + { + id: 'channel2', + name: 'channel-2', + display_name: 'Channel 2', + type: 'P', + } as Channel, + ]; + + const defaultProps = { + recapName: '', + setRecapName: jest.fn(), + recapType: null as 'selected' | 'all_unreads' | null, + setRecapType: jest.fn(), + unreadChannels: mockUnreadChannels, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Recap Name Input', () => { + it('should render name input field', () => { + renderWithContext(); + + expect(screen.getByPlaceholderText('Give your recap a name')).toBeInTheDocument(); + }); + + it('should display current recap name value', () => { + renderWithContext( + , + ); + + const input = screen.getByPlaceholderText('Give your recap a name') as HTMLInputElement; + expect(input.value).toBe('My Test Recap'); + }); + + it('should call setRecapName when name is changed', async () => { + const setRecapName = jest.fn(); + renderWithContext( + , + ); + + const input = screen.getByPlaceholderText('Give your recap a name'); + await userEvent.type(input, 'New Recap'); + + expect(setRecapName).toHaveBeenCalled(); + }); + + it('should enforce maxLength of 100 characters', () => { + renderWithContext(); + + const input = screen.getByPlaceholderText('Give your recap a name') as HTMLInputElement; + expect(input.maxLength).toBe(100); + }); + }); + + describe('Recap Type Selection', () => { + it('should render both recap type options', () => { + renderWithContext(); + + expect(screen.getByText('Recap selected channels')).toBeInTheDocument(); + expect(screen.getByText('Recap all my unreads')).toBeInTheDocument(); + }); + + it('should call setRecapType when selected channels option is clicked', async () => { + const setRecapType = jest.fn(); + renderWithContext( + , + ); + + const selectedChannelsButton = screen.getByText('Recap selected channels').closest('button'); + await userEvent.click(selectedChannelsButton!); + + expect(setRecapType).toHaveBeenCalledWith('selected'); + }); + + it('should call setRecapType when all unreads option is clicked', async () => { + const setRecapType = jest.fn(); + renderWithContext( + , + ); + + const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button'); + await userEvent.click(allUnreadsButton!); + + expect(setRecapType).toHaveBeenCalledWith('all_unreads'); + }); + + it('should show selected state for selected channels option', () => { + renderWithContext( + , + ); + + const selectedButton = screen.getByText('Recap selected channels').closest('button'); + expect(selectedButton).toHaveClass('selected'); + }); + + it('should show selected state for all unreads option', () => { + renderWithContext( + , + ); + + const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button'); + expect(allUnreadsButton).toHaveClass('selected'); + }); + + it('should show check icon when selected channels is selected', () => { + renderWithContext( + , + ); + + const selectedButton = screen.getByText('Recap selected channels').closest('button'); + const checkIcon = selectedButton?.querySelector('.selected-icon'); + expect(checkIcon).toBeInTheDocument(); + }); + + it('should show check icon when all unreads is selected', () => { + renderWithContext( + , + ); + + const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button'); + const checkIcon = allUnreadsButton?.querySelector('.selected-icon'); + expect(checkIcon).toBeInTheDocument(); + }); + }); + + describe('Unread Channels Handling', () => { + it('should disable all unreads option when no unread channels', () => { + renderWithContext( + , + ); + + const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button'); + expect(allUnreadsButton).toBeDisabled(); + expect(allUnreadsButton).toHaveClass('disabled'); + }); + + it('should enable all unreads option when unread channels exist', () => { + renderWithContext(); + + const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button'); + expect(allUnreadsButton).not.toBeDisabled(); + expect(allUnreadsButton).not.toHaveClass('disabled'); + }); + + it('should not call setRecapType when all unreads is clicked with no unread channels', async () => { + const setRecapType = jest.fn(); + renderWithContext( + , + ); + + const allUnreadsButton = screen.getByText('Recap all my unreads').closest('button'); + await userEvent.click(allUnreadsButton!); + + expect(setRecapType).not.toHaveBeenCalled(); + }); + + it('should show tooltip when all unreads option is disabled', () => { + renderWithContext( + , + ); + + // The WithTooltip component wraps the button when there are no unreads + expect(screen.getByText('Recap all my unreads')).toBeInTheDocument(); + }); + }); + + describe('Form Labels', () => { + it('should display name label', () => { + renderWithContext(); + + expect(screen.getByText('Give your recap a name')).toBeInTheDocument(); + }); + + it('should display type selection label', () => { + renderWithContext(); + + expect(screen.getByText('What type of recap would you like?')).toBeInTheDocument(); + }); + }); + + describe('Type Descriptions', () => { + it('should show description for selected channels option', () => { + renderWithContext(); + + expect(screen.getByText('Choose the channels you would like included in your recap')).toBeInTheDocument(); + }); + + it('should show description for all unreads option', () => { + renderWithContext(); + + expect(screen.getByText('Copilot will create a recap of all unreads across your channels.')).toBeInTheDocument(); + }); + }); +}); + diff --git a/webapp/channels/src/components/create_recap_modal/recap_configuration.tsx b/webapp/channels/src/components/create_recap_modal/recap_configuration.tsx new file mode 100644 index 00000000000..4a5b835cabe --- /dev/null +++ b/webapp/channels/src/components/create_recap_modal/recap_configuration.tsx @@ -0,0 +1,130 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {useIntl, FormattedMessage} from 'react-intl'; + +import {ProductChannelsIcon, LightningBoltOutlineIcon, CheckCircleIcon} from '@mattermost/compass-icons/components'; +import type {Channel} from '@mattermost/types/channels'; + +import WithTooltip from 'components/with_tooltip'; + +const RECAP_NAME_MAX_LENGTH = 100; + +type Props = { + recapName: string; + setRecapName: (name: string) => void; + recapType: 'selected' | 'all_unreads' | null; + setRecapType: (type: 'selected' | 'all_unreads') => void; + unreadChannels: Channel[]; +}; + +const RecapConfiguration = ({recapName, setRecapName, recapType, setRecapType, unreadChannels}: Props) => { + const {formatMessage} = useIntl(); + const hasUnreadChannels = unreadChannels.length > 0; + + const allUnreadsButton = ( + + ); + + return ( +
+
+ +
+ setRecapName(e.target.value)} + maxLength={RECAP_NAME_MAX_LENGTH} + /> +
+
+ +
+
+ +
+
+ + + {hasUnreadChannels ? allUnreadsButton : ( + + {allUnreadsButton} + + )} +
+
+
+ ); +}; + +export default RecapConfiguration; + diff --git a/webapp/channels/src/components/create_user_groups_modal/__snapshots__/create_user_groups_modal.test.tsx.snap b/webapp/channels/src/components/create_user_groups_modal/__snapshots__/create_user_groups_modal.test.tsx.snap index 4ceeaee9ae9..2c638e06281 100644 --- a/webapp/channels/src/components/create_user_groups_modal/__snapshots__/create_user_groups_modal.test.tsx.snap +++ b/webapp/channels/src/components/create_user_groups_modal/__snapshots__/create_user_groups_modal.test.tsx.snap @@ -127,7 +127,7 @@ exports[`component/create_user_groups_modal should match snapshot with back butt > @@ -271,7 +271,7 @@ exports[`component/create_user_groups_modal should match snapshot without back b > diff --git a/webapp/channels/src/components/create_user_groups_modal/create_user_groups_modal.tsx b/webapp/channels/src/components/create_user_groups_modal/create_user_groups_modal.tsx index 7bf8720a2bd..994b351a18e 100644 --- a/webapp/channels/src/components/create_user_groups_modal/create_user_groups_modal.tsx +++ b/webapp/channels/src/components/create_user_groups_modal/create_user_groups_modal.tsx @@ -305,7 +305,7 @@ export class CreateUserGroupsModal extends React.PureComponent {
diff --git a/webapp/channels/src/components/delete_post_modal/delete_post_modal.tsx b/webapp/channels/src/components/delete_post_modal/delete_post_modal.tsx index 32d73518df5..77e4b0ce645 100644 --- a/webapp/channels/src/components/delete_post_modal/delete_post_modal.tsx +++ b/webapp/channels/src/components/delete_post_modal/delete_post_modal.tsx @@ -19,8 +19,6 @@ const urlFormatForDMGMPermalink = '/:teamName/messages/:username/:postid'; const urlFormatForChannelPermalink = '/:teamName/channels/:channelname/:postid'; type Props = { - channelName?: string; - teamName?: string; post: Post; commentCount: number; isRHS: boolean; diff --git a/webapp/channels/src/components/dialog_router/interactive_dialog_adapter.tsx b/webapp/channels/src/components/dialog_router/interactive_dialog_adapter.tsx index 972861fee85..aa08cf957b4 100644 --- a/webapp/channels/src/components/dialog_router/interactive_dialog_adapter.tsx +++ b/webapp/channels/src/components/dialog_router/interactive_dialog_adapter.tsx @@ -21,7 +21,6 @@ import { type ConversionOptions, type ValidationError, } from 'utils/dialog_conversion'; -import type EmojiMap from 'utils/emoji_map'; import type {DoAppCallResult} from 'types/apps'; @@ -46,7 +45,6 @@ interface Props extends WrappedComponentProps { // Enhanced functionality sourceUrl?: string; // Optional URL for form refresh functionality - emojiMap?: EmojiMap; conversionOptions?: Partial; // Required actions diff --git a/webapp/channels/src/components/dnd_custom_time_picker_modal/dnd_custom_time_picker_modal.tsx b/webapp/channels/src/components/dnd_custom_time_picker_modal/dnd_custom_time_picker_modal.tsx index 2b93f5752ed..3b27ce4698b 100644 --- a/webapp/channels/src/components/dnd_custom_time_picker_modal/dnd_custom_time_picker_modal.tsx +++ b/webapp/channels/src/components/dnd_custom_time_picker_modal/dnd_custom_time_picker_modal.tsx @@ -21,7 +21,6 @@ type Props = { onExited: () => void; userId: string; currentDate: Date; - locale: string; timezone?: string; actions: { diff --git a/webapp/channels/src/components/dnd_custom_time_picker_modal/index.ts b/webapp/channels/src/components/dnd_custom_time_picker_modal/index.ts index 1bc80b6ad5c..edeeaf98c07 100644 --- a/webapp/channels/src/components/dnd_custom_time_picker_modal/index.ts +++ b/webapp/channels/src/components/dnd_custom_time_picker_modal/index.ts @@ -10,8 +10,6 @@ import {setStatus} from 'mattermost-redux/actions/users'; import {getCurrentTimezone} from 'mattermost-redux/selectors/entities/timezone'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; -import {getCurrentLocale} from 'selectors/i18n'; - import {makeAsyncComponent} from 'components/async_load'; import type {GlobalState} from 'types/store'; @@ -20,12 +18,10 @@ const DndCustomTimePicker = makeAsyncComponent('DndCustomTimePicker', React.lazy function mapStateToProps(state: GlobalState) { const userId = getCurrentUserId(state); - const locale = getCurrentLocale(state); const timezone = getCurrentTimezone(state); return { userId, - locale, timezone, }; } diff --git a/webapp/channels/src/components/dot_menu/dot_menu.tsx b/webapp/channels/src/components/dot_menu/dot_menu.tsx index a9a53362a0b..5181ac946a4 100644 --- a/webapp/channels/src/components/dot_menu/dot_menu.tsx +++ b/webapp/channels/src/components/dot_menu/dot_menu.tsx @@ -155,7 +155,6 @@ type Props = { canDelete: boolean; userId: string; threadId: UserThread['id']; - isCollapsedThreadsEnabled: boolean; isFollowingThread?: boolean; isMentionedInRootPost?: boolean; threadReplyCount?: number; @@ -544,21 +543,21 @@ export class DotMenuClass extends React.PureComponent { const saveFlag = ( ); const pinPost = ( ); const unPinPost = ( ); diff --git a/webapp/channels/src/components/dot_menu/index.ts b/webapp/channels/src/components/dot_menu/index.ts index 285c8a82679..b75be2b38ec 100644 --- a/webapp/channels/src/components/dot_menu/index.ts +++ b/webapp/channels/src/components/dot_menu/index.ts @@ -123,7 +123,6 @@ function makeMapStateToProps() { threadId, isFollowingThread, isMentionedInRootPost, - isCollapsedThreadsEnabled: collapsedThreads, threadReplyCount, isMobileView: getIsMobileView(state), timezone: getCurrentTimezone(state), diff --git a/webapp/channels/src/components/drafts/draft_row.tsx b/webapp/channels/src/components/drafts/draft_row.tsx index e695a508769..b78126de808 100644 --- a/webapp/channels/src/components/drafts/draft_row.tsx +++ b/webapp/channels/src/components/drafts/draft_row.tsx @@ -60,8 +60,6 @@ type Props = { isRemote?: boolean; scrollIntoView?: boolean; containerClassName?: string; - dataTestId?: string; - dataPostId?: string; } const mockLastBlurAt = {current: 0}; diff --git a/webapp/channels/src/components/drafts/drafts_link/drafts_link.tsx b/webapp/channels/src/components/drafts/drafts_link/drafts_link.tsx index 3a14b0d50dd..6cc66da85e9 100644 --- a/webapp/channels/src/components/drafts/drafts_link/drafts_link.tsx +++ b/webapp/channels/src/components/drafts/drafts_link/drafts_link.tsx @@ -97,7 +97,7 @@ function DraftsLink() { return (

diff --git a/webapp/channels/src/components/edit_channel_purpose_modal/edit_channel_purpose_modal.tsx b/webapp/channels/src/components/edit_channel_purpose_modal/edit_channel_purpose_modal.tsx index 2fc1f29800f..3749b913d70 100644 --- a/webapp/channels/src/components/edit_channel_purpose_modal/edit_channel_purpose_modal.tsx +++ b/webapp/channels/src/components/edit_channel_purpose_modal/edit_channel_purpose_modal.tsx @@ -150,7 +150,7 @@ export class EditChannelPurposeModal extends React.PureComponent { channelPurposeModal = ( ); } diff --git a/webapp/channels/src/components/emoji/add_emoji/__snapshots__/add_emoji.test.tsx.snap b/webapp/channels/src/components/emoji/add_emoji/__snapshots__/add_emoji.test.tsx.snap index d8b5c1c8d57..93ef96dbf92 100644 --- a/webapp/channels/src/components/emoji/add_emoji/__snapshots__/add_emoji.test.tsx.snap +++ b/webapp/channels/src/components/emoji/add_emoji/__snapshots__/add_emoji.test.tsx.snap @@ -52,7 +52,7 @@ exports[`components/emoji/components/AddEmoji should match snapshot 1`] = ` className="form__help" >
@@ -97,7 +97,7 @@ exports[`components/emoji/components/AddEmoji should match snapshot 1`] = ` className="form__help" >
@@ -196,7 +196,7 @@ exports[`components/emoji/components/AddEmoji should select a file and match sna className="form__help" >
@@ -244,7 +244,7 @@ exports[`components/emoji/components/AddEmoji should select a file and match sna className="form__help" >
@@ -376,7 +376,7 @@ exports[`components/emoji/components/AddEmoji should update emoji name and match className="form__help" >
@@ -421,7 +421,7 @@ exports[`components/emoji/components/AddEmoji should update emoji name and match className="form__help" > diff --git a/webapp/channels/src/components/emoji/add_emoji/add_emoji.tsx b/webapp/channels/src/components/emoji/add_emoji/add_emoji.tsx index 788a72cd34b..ec5450b72ab 100644 --- a/webapp/channels/src/components/emoji/add_emoji/add_emoji.tsx +++ b/webapp/channels/src/components/emoji/add_emoji/add_emoji.tsx @@ -173,7 +173,7 @@ export default class AddEmoji extends React.PureComponent ), }); @@ -326,7 +326,7 @@ export default class AddEmoji extends React.PureComponent @@ -362,7 +362,7 @@ export default class AddEmoji extends React.PureComponent diff --git a/webapp/channels/src/components/emoji/emoji_page.test.tsx b/webapp/channels/src/components/emoji/emoji_page.test.tsx index 45b7f605bbe..8d3e625ce90 100644 --- a/webapp/channels/src/components/emoji/emoji_page.test.tsx +++ b/webapp/channels/src/components/emoji/emoji_page.test.tsx @@ -6,8 +6,6 @@ import {shallow} from 'enzyme'; import React from 'react'; import {Link} from 'react-router-dom'; -import type {Theme} from 'mattermost-redux/selectors/entities/preferences'; - import AnyTeamPermissionGate from 'components/permissions_gates/any_team_permission_gate'; import EmojiList from './emoji_list'; @@ -15,21 +13,17 @@ import EmojiPage from './emoji_page'; jest.mock('utils/utils', () => ({ localizeMessage: jest.fn().mockReturnValue('Custom Emoji'), - resetTheme: jest.fn(), - applyTheme: jest.fn(), })); describe('EmojiPage', () => { const mockLoadRolesIfNeeded = jest.fn(); const mockScrollToTop = jest.fn(); - const mockCurrentTheme = {} as Theme; const defaultProps = { teamName: 'team', teamDisplayName: 'Team Display Name', siteName: 'Site Name', scrollToTop: mockScrollToTop, - currentTheme: mockCurrentTheme, actions: { loadRolesIfNeeded: mockLoadRolesIfNeeded, }, diff --git a/webapp/channels/src/components/emoji/emoji_page.tsx b/webapp/channels/src/components/emoji/emoji_page.tsx index 0f5b1f246b9..900bd21bd9c 100644 --- a/webapp/channels/src/components/emoji/emoji_page.tsx +++ b/webapp/channels/src/components/emoji/emoji_page.tsx @@ -6,12 +6,9 @@ import {FormattedMessage, useIntl} from 'react-intl'; import {Link} from 'react-router-dom'; import Permissions from 'mattermost-redux/constants/permissions'; -import type {Theme} from 'mattermost-redux/selectors/entities/preferences'; import AnyTeamPermissionGate from 'components/permissions_gates/any_team_permission_gate'; -import * as Utils from 'utils/utils'; - import EmojiList from './emoji_list'; type Props = { @@ -19,7 +16,6 @@ type Props = { teamDisplayName?: string; siteName?: string; scrollToTop(): void; - currentTheme: Theme; actions: { loadRolesIfNeeded(roles: Iterable): void; }; @@ -33,7 +29,6 @@ export default function EmojiPage({ teamName = '', siteName = '', scrollToTop, - currentTheme, actions, }: Props) { const intl = useIntl(); @@ -41,11 +36,6 @@ export default function EmojiPage({ useEffect(() => { updateTitle(); actions.loadRolesIfNeeded(ROLES); - Utils.resetTheme(); - - return () => { - Utils.applyTheme(currentTheme); - }; }, []); useEffect(() => { diff --git a/webapp/channels/src/components/emoji/index.ts b/webapp/channels/src/components/emoji/index.ts index fdc9350b899..167bd2bdb32 100644 --- a/webapp/channels/src/components/emoji/index.ts +++ b/webapp/channels/src/components/emoji/index.ts @@ -6,7 +6,6 @@ import {bindActionCreators} from 'redux'; import type {Dispatch} from 'redux'; import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles'; -import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; import EmojiPage from 'components/emoji/emoji_page'; @@ -20,7 +19,6 @@ function mapStateToProps(state: GlobalState) { teamName: team?.name, teamDisplayName: team?.display_name, siteName: state.entities.general.config.SiteName, - currentTheme: getTheme(state), }; } diff --git a/webapp/channels/src/components/emoji_picker/__snapshots__/emoji_picker.test.tsx.snap b/webapp/channels/src/components/emoji_picker/__snapshots__/emoji_picker.test.tsx.snap index ab22850e9df..0eaecc364a9 100644 --- a/webapp/channels/src/components/emoji_picker/__snapshots__/emoji_picker.test.tsx.snap +++ b/webapp/channels/src/components/emoji_picker/__snapshots__/emoji_picker.test.tsx.snap @@ -136,7 +136,7 @@ exports[`components/emoji_picker/EmojiPicker should match snapshot 1`] = ` role="grid" > @@ -1708,7 +1708,7 @@ exports[`components/integrations/AbstractIncomingWebhook should match snapshot, className="form__help" > diff --git a/webapp/channels/src/components/integrations/__snapshots__/abstract_oauth_app.test.tsx.snap b/webapp/channels/src/components/integrations/__snapshots__/abstract_oauth_app.test.tsx.snap index 0d17d874fc2..5960d3829cd 100644 --- a/webapp/channels/src/components/integrations/__snapshots__/abstract_oauth_app.test.tsx.snap +++ b/webapp/channels/src/components/integrations/__snapshots__/abstract_oauth_app.test.tsx.snap @@ -9,7 +9,7 @@ exports[`components/integrations/AbstractOAuthApp should match snapshot 1`] = ` to="/test/integrations/oauth2-apps" > @@ -375,7 +375,7 @@ exports[`components/integrations/AbstractOAuthApp should match snapshot, display to="/test/integrations/oauth2-apps" > diff --git a/webapp/channels/src/components/integrations/abstract_command.tsx b/webapp/channels/src/components/integrations/abstract_command.tsx index 1bb42993917..aea2ddf2a9f 100644 --- a/webapp/channels/src/components/integrations/abstract_command.tsx +++ b/webapp/channels/src/components/integrations/abstract_command.tsx @@ -609,7 +609,7 @@ export default class AbstractCommand extends React.PureComponent {
diff --git a/webapp/channels/src/components/integrations/abstract_incoming_webhook.tsx b/webapp/channels/src/components/integrations/abstract_incoming_webhook.tsx index e0f60aebba5..91f07009caf 100644 --- a/webapp/channels/src/components/integrations/abstract_incoming_webhook.tsx +++ b/webapp/channels/src/components/integrations/abstract_incoming_webhook.tsx @@ -335,7 +335,7 @@ export default class AbstractIncomingWebhook extends PureComponent
diff --git a/webapp/channels/src/components/integrations/abstract_oauth_app.tsx b/webapp/channels/src/components/integrations/abstract_oauth_app.tsx index 19c89ceda0d..7dffb62f745 100644 --- a/webapp/channels/src/components/integrations/abstract_oauth_app.tsx +++ b/webapp/channels/src/components/integrations/abstract_oauth_app.tsx @@ -375,7 +375,7 @@ export default class AbstractOAuthApp extends React.PureComponent diff --git a/webapp/channels/src/components/integrations/add_command/add_command.tsx b/webapp/channels/src/components/integrations/add_command/add_command.tsx index c268b4c695e..756dd970779 100644 --- a/webapp/channels/src/components/integrations/add_command/add_command.tsx +++ b/webapp/channels/src/components/integrations/add_command/add_command.tsx @@ -3,7 +3,6 @@ import React, {useState} from 'react'; import {useIntl} from 'react-intl'; -import type {MessageDescriptor} from 'react-intl'; import {useHistory} from 'react-router-dom'; import type {Command} from '@mattermost/types/integrations'; @@ -32,9 +31,9 @@ export type Props = { const AddCommand = ({team, actions}: Props) => { const history = useHistory(); const {formatMessage} = useIntl(); - const headerMessage = formatMessage({id: ('integrations.add'), defaultMessage: 'Add'}) as MessageDescriptor; - const footerMessage = formatMessage({id: ('add_command.save'), defaultMessage: 'Save'}) as MessageDescriptor; - const loadingMessage = formatMessage({id: ('add_command.saving'), defaultMessage: 'Saving...'}) as MessageDescriptor; + const headerMessage = formatMessage({id: 'integrations.add', defaultMessage: 'Add'}); + const footerMessage = formatMessage({id: 'add_command.save', defaultMessage: 'Save'}); + const loadingMessage = formatMessage({id: 'add_command.saving', defaultMessage: 'Saving...'}); const [serverError, setServerError] = useState(''); const addCommand = async (command: Command) => { diff --git a/webapp/channels/src/components/integrations/bots/bots.tsx b/webapp/channels/src/components/integrations/bots/bots.tsx index 340bed7305b..298e518d0e6 100644 --- a/webapp/channels/src/components/integrations/bots/bots.tsx +++ b/webapp/channels/src/components/integrations/bots/bots.tsx @@ -237,6 +237,7 @@ export default class Bots extends React.PureComponent { emptyTextSearch={ {chunks}, diff --git a/webapp/channels/src/components/integrations/confirm_integration/__snapshots__/confirm_integration.test.tsx.snap b/webapp/channels/src/components/integrations/confirm_integration/__snapshots__/confirm_integration.test.tsx.snap index aa8c2e94236..8ba93d23cd9 100644 --- a/webapp/channels/src/components/integrations/confirm_integration/__snapshots__/confirm_integration.test.tsx.snap +++ b/webapp/channels/src/components/integrations/confirm_integration/__snapshots__/confirm_integration.test.tsx.snap @@ -206,7 +206,7 @@ exports[`components/integrations/ConfirmIntegration should match snapshot, oauth key="add_oauth_app.doneHelp" > @@ -450,12 +450,11 @@ exports[`components/integrations/ConfirmIntegration should match snapshot, outgo />
diff --git a/webapp/channels/src/components/integrations/confirm_integration/confirm_integration.tsx b/webapp/channels/src/components/integrations/confirm_integration/confirm_integration.tsx index c16dc464c5b..50b62119903 100644 --- a/webapp/channels/src/components/integrations/confirm_integration/confirm_integration.tsx +++ b/webapp/channels/src/components/integrations/confirm_integration/confirm_integration.tsx @@ -208,7 +208,7 @@ const ConfirmIntegration = ({team, location, commands, oauthApps, incomingHooks,

( ( ); @@ -343,9 +342,8 @@ const ConfirmIntegration = ({team, location, commands, oauthApps, incomingHooks,
{chunks}, }} /> @@ -417,10 +415,10 @@ const ConfirmIntegration = ({team, location, commands, oauthApps, incomingHooks,

{msg}, + strong: (msg) => {msg}, link: (msg) => ( { header={ } addText={ @@ -102,12 +102,13 @@ export default class InstalledCommands extends React.PureComponent { emptyText={ } emptyTextSearch={ {chunks}, diff --git a/webapp/channels/src/components/integrations/installed_incoming_webhooks/installed_incoming_webhooks.tsx b/webapp/channels/src/components/integrations/installed_incoming_webhooks/installed_incoming_webhooks.tsx index e4b329835b3..137c3cb57e5 100644 --- a/webapp/channels/src/components/integrations/installed_incoming_webhooks/installed_incoming_webhooks.tsx +++ b/webapp/channels/src/components/integrations/installed_incoming_webhooks/installed_incoming_webhooks.tsx @@ -124,7 +124,7 @@ export default class InstalledIncomingWebhooks extends React.PureComponent } addText={ @@ -144,7 +144,11 @@ export default class InstalledIncomingWebhooks extends React.PureComponent {chunks}, + }} /> } helpText={ diff --git a/webapp/channels/src/components/integrations/installed_oauth_apps/__snapshots__/installed_oauth_apps.test.tsx.snap b/webapp/channels/src/components/integrations/installed_oauth_apps/__snapshots__/installed_oauth_apps.test.tsx.snap index c4c70f1f908..9254d5ae4ca 100644 --- a/webapp/channels/src/components/integrations/installed_oauth_apps/__snapshots__/installed_oauth_apps.test.tsx.snap +++ b/webapp/channels/src/components/integrations/installed_oauth_apps/__snapshots__/installed_oauth_apps.test.tsx.snap @@ -13,8 +13,13 @@ exports[`components/integrations/InstalledOAuthApps should match snapshot 1`] = } emptyTextSearch={ } header={ diff --git a/webapp/channels/src/components/integrations/installed_oauth_apps/installed_oauth_apps.tsx b/webapp/channels/src/components/integrations/installed_oauth_apps/installed_oauth_apps.tsx index 37c117e25ee..137229490dc 100644 --- a/webapp/channels/src/components/integrations/installed_oauth_apps/installed_oauth_apps.tsx +++ b/webapp/channels/src/components/integrations/installed_oauth_apps/installed_oauth_apps.tsx @@ -184,7 +184,13 @@ export default class InstalledOAuthApps extends React.PureComponent {chunks}, + }} /> } searchPlaceholder={localizeMessage({id: 'installed_oauth_apps.search', defaultMessage: 'Search OAuth 2.0 Applications'})} diff --git a/webapp/channels/src/components/integrations/installed_outgoing_webhooks/__snapshots__/installed_outgoing_webhooks.test.tsx.snap b/webapp/channels/src/components/integrations/installed_outgoing_webhooks/__snapshots__/installed_outgoing_webhooks.test.tsx.snap index b44d149ed3d..be857981ef4 100644 --- a/webapp/channels/src/components/integrations/installed_outgoing_webhooks/__snapshots__/installed_outgoing_webhooks.test.tsx.snap +++ b/webapp/channels/src/components/integrations/installed_outgoing_webhooks/__snapshots__/installed_outgoing_webhooks.test.tsx.snap @@ -252,7 +252,7 @@ exports[`components/integrations/InstalledOutgoingWebhooks should match snapshot } header={ } diff --git a/webapp/channels/src/components/integrations/installed_outgoing_webhooks/installed_outgoing_webhooks.tsx b/webapp/channels/src/components/integrations/installed_outgoing_webhooks/installed_outgoing_webhooks.tsx index 38fee6525ae..0da6ca38461 100644 --- a/webapp/channels/src/components/integrations/installed_outgoing_webhooks/installed_outgoing_webhooks.tsx +++ b/webapp/channels/src/components/integrations/installed_outgoing_webhooks/installed_outgoing_webhooks.tsx @@ -162,7 +162,7 @@ export default class InstalledOutgoingWebhooks extends React.PureComponent } addText={ @@ -186,6 +186,9 @@ export default class InstalledOutgoingWebhooks extends React.PureComponent {chunks}, diff --git a/webapp/channels/src/components/integrations/integrations.tsx b/webapp/channels/src/components/integrations/integrations.tsx index e9e6ee6876f..910feff65ed 100644 --- a/webapp/channels/src/components/integrations/integrations.tsx +++ b/webapp/channels/src/components/integrations/integrations.tsx @@ -120,7 +120,7 @@ export default class Integrations extends React.PureComponent { description={ } link={'/' + this.props.team.name + '/integrations/commands'} @@ -147,7 +147,7 @@ export default class Integrations extends React.PureComponent { description={ } link={'/' + this.props.team.name + '/integrations/oauth2-apps'} diff --git a/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/abstract_outgoing_oauth_connection.test.tsx.snap b/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/abstract_outgoing_oauth_connection.test.tsx.snap index 26634c1af31..6f1b22fc745 100644 --- a/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/abstract_outgoing_oauth_connection.test.tsx.snap +++ b/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/abstract_outgoing_oauth_connection.test.tsx.snap @@ -15,7 +15,7 @@ exports[`components/integrations/AbstractOutgoingOAuthConnection should match sn "listen": [Function], "location": Object { "hash": "", - "pathname": "undefinedundefined", + "pathname": "/", "search": "", "state": undefined, }, @@ -478,7 +478,7 @@ exports[`components/integrations/AbstractOutgoingOAuthConnection should match sn Get help with configuring outgoing OAuth connections @@ -590,7 +590,7 @@ exports[`components/integrations/AbstractOutgoingOAuthConnection should match sn "listen": [Function], "location": Object { "hash": "", - "pathname": "undefinedundefined", + "pathname": "/", "search": "", "state": undefined, }, @@ -1073,7 +1073,7 @@ exports[`components/integrations/AbstractOutgoingOAuthConnection should match sn Get help with configuring outgoing OAuth connections diff --git a/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/add_outgoing_oauth_connection.test.tsx.snap b/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/add_outgoing_oauth_connection.test.tsx.snap index 3997ceaa575..697f7acb5b3 100644 --- a/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/add_outgoing_oauth_connection.test.tsx.snap +++ b/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/add_outgoing_oauth_connection.test.tsx.snap @@ -15,7 +15,7 @@ exports[`components/integrations/AddOutgoingOAuthConnection should match snapsho "listen": [Function], "location": Object { "hash": "", - "pathname": "undefinedundefined", + "pathname": "/", "search": "", "state": undefined, }, @@ -467,7 +467,7 @@ exports[`components/integrations/AddOutgoingOAuthConnection should match snapsho Get help with configuring outgoing OAuth connections diff --git a/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/edit_outgoing_oauth_connection.test.tsx.snap b/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/edit_outgoing_oauth_connection.test.tsx.snap index 4e5af9625ca..80e26c04646 100644 --- a/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/edit_outgoing_oauth_connection.test.tsx.snap +++ b/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/edit_outgoing_oauth_connection.test.tsx.snap @@ -15,7 +15,7 @@ exports[`components/integrations/EditOutgoingOAuthConnection should match snapsh "listen": [Function], "location": Object { "hash": "", - "pathname": "undefinedundefined", + "pathname": "/", "search": "", "state": undefined, }, @@ -643,7 +643,7 @@ https://myothersite.com/api/v2" Get help with configuring outgoing OAuth connections @@ -756,7 +756,7 @@ exports[`components/integrations/EditOutgoingOAuthConnection should match snapsh "listen": [Function], "location": Object { "hash": "", - "pathname": "undefinedundefined", + "pathname": "/", "search": "", "state": undefined, }, @@ -1385,7 +1385,7 @@ https://myothersite.com/api/v2" Get help with configuring outgoing OAuth connections @@ -1498,7 +1498,7 @@ exports[`components/integrations/EditOutgoingOAuthConnection should match snapsh "listen": [Function], "location": Object { "hash": "", - "pathname": "undefinedundefined", + "pathname": "/", "search": "", "state": undefined, }, @@ -2126,7 +2126,7 @@ https://myothersite.com/api/v2" Get help with configuring outgoing OAuth connections diff --git a/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/installed_outgoing_oauth_connections.test.tsx.snap b/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/installed_outgoing_oauth_connections.test.tsx.snap index c96ce8a83af..390cf7b688f 100644 --- a/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/installed_outgoing_oauth_connections.test.tsx.snap +++ b/webapp/channels/src/components/integrations/outgoing_oauth_connections/__snapshots__/installed_outgoing_oauth_connections.test.tsx.snap @@ -15,7 +15,7 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match "listen": [Function], "location": Object { "hash": "", - "pathname": "undefinedundefined", + "pathname": "/", "search": "", "state": undefined, }, @@ -190,7 +190,7 @@ exports[`components/integrations/InstalledOutgoingOAuthConnections should match Create { emptyTextSearch={ } diff --git a/webapp/channels/src/components/interactive_dialog/interactive_dialog.tsx b/webapp/channels/src/components/interactive_dialog/interactive_dialog.tsx index 3b294d0de4e..b63c7bffbcf 100644 --- a/webapp/channels/src/components/interactive_dialog/interactive_dialog.tsx +++ b/webapp/channels/src/components/interactive_dialog/interactive_dialog.tsx @@ -74,8 +74,7 @@ export default class InteractiveDialog extends React.PureComponent if (error) { errors[elem.name] = ( ); diff --git a/webapp/channels/src/components/intl_provider/intl_provider.tsx b/webapp/channels/src/components/intl_provider/intl_provider.tsx index 9f8ec2b3c29..0d040a981a2 100644 --- a/webapp/channels/src/components/intl_provider/intl_provider.tsx +++ b/webapp/channels/src/components/intl_provider/intl_provider.tsx @@ -1,15 +1,16 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; +import React, {useEffect} from 'react'; import type {ReactNode} from 'react'; -import {IntlProvider as BaseIntlProvider} from 'react-intl'; +import {IntlProvider as BaseIntlProvider, useIntl} from 'react-intl'; import type {IntlConfig} from 'react-intl'; import {Client4} from 'mattermost-redux/client'; import {setLocalizeFunction} from 'mattermost-redux/utils/i18n_utils'; import * as I18n from 'i18n/i18n'; +import {setIntl} from 'utils/i18n'; import {localizeMessage} from 'utils/utils'; type Props = { @@ -21,6 +22,20 @@ type Props = { }; }; +/** + * Captures the intl instance from BaseIntlProvider and stores it + * so that getIntl() can return the same instance. + */ +function IntlCapture() { + const intl = useIntl(); + + useEffect(() => { + setIntl(intl); + }, [intl]); + + return null; +} + export default class IntlProvider extends React.PureComponent { componentDidMount() { // Pass localization function back to mattermost-redux @@ -68,6 +83,7 @@ export default class IntlProvider extends React.PureComponent { textComponent='span' wrapRichTextChunksInFragment={false} > + {this.props.children} ); diff --git a/webapp/channels/src/components/invitation_modal/no_permissions_view.tsx b/webapp/channels/src/components/invitation_modal/no_permissions_view.tsx index 96cc3390b57..bd0d11f047b 100644 --- a/webapp/channels/src/components/invitation_modal/no_permissions_view.tsx +++ b/webapp/channels/src/components/invitation_modal/no_permissions_view.tsx @@ -5,15 +5,13 @@ import React from 'react'; import {Modal} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; -import AccessDeniedSvg from 'components/common/svg_images_components/access_denied_svg'; +import AccessProblemSVG from 'components/common/svg_images_components/access_problem_svg'; import './no_permissions_view.scss'; type Props = { footerClass: string; onDone: () => void; - title?: string; - description?: string; } export default function NoPermissionsView(props: Props) { @@ -38,9 +36,9 @@ export default function NoPermissionsView(props: Props) { defaultMessage='You do not have permissions to add users or guests. If this seems like an error, please reach out to your system administrator.' /> - diff --git a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts.ts b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts.ts index 65f4d0c4108..d093716cf0f 100644 --- a/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts.ts +++ b/webapp/channels/src/components/keyboard_shortcuts/keyboard_shortcuts_sequence/keyboard_shortcuts.ts @@ -316,21 +316,21 @@ export const KEYBOARD_SHORTCUTS = { msgMarkdownCode: defineMessages({ default: { id: 'shortcuts.msgs.markdown.code', - defaultMessage: 'Code', + defaultMessage: 'Code:\tCtrl|Alt|C', }, mac: { id: 'shortcuts.msgs.markdown.code.mac', - defaultMessage: 'Code', + defaultMessage: 'Code:\t⌘|⌥|C', }, }), msgMarkdownStrike: defineMessages({ default: { id: 'shortcuts.msgs.markdown.strike', - defaultMessage: 'Strikethrough:\tCtrl|Shift|X', + defaultMessage: 'Strikethrough:\tShift|Alt|X', }, mac: { id: 'shortcuts.msgs.markdown.strike.mac', - defaultMessage: 'Strikethrough:\t⌘|Shift|X', + defaultMessage: 'Strikethrough:\tShift|⌥|X', }, }), msgMarkdownH3: defineMessages({ @@ -346,31 +346,31 @@ export const KEYBOARD_SHORTCUTS = { msgMarkdownQuote: defineMessages({ default: { id: 'shortcuts.msgs.markdown.quote', - defaultMessage: 'Quote', + defaultMessage: 'Quote:\tShift|Alt|9', }, mac: { id: 'shortcuts.msgs.markdown.quote.mac', - defaultMessage: 'Quote', + defaultMessage: 'Quote:\tShift|⌥|9', }, }), msgMarkdownOl: defineMessages({ default: { id: 'shortcuts.msgs.markdown.ordered', - defaultMessage: 'Numbered List', + defaultMessage: 'Numbered List:\tShift|Alt|7', }, mac: { id: 'shortcuts.msgs.markdown.ordered.mac', - defaultMessage: 'Numbered List', + defaultMessage: 'Numbered List:\tShift|⌥|7', }, }), msgMarkdownUl: defineMessages({ default: { id: 'shortcuts.msgs.markdown.unordered', - defaultMessage: 'Bulleted List', + defaultMessage: 'Bulleted List\tShift|Alt|8', }, mac: { id: 'shortcuts.msgs.markdown.unordered.mac', - defaultMessage: 'Bulleted List', + defaultMessage: 'Bulleted List:\tShift|⌥|8', }, }), msgShowFormatting: defineMessages({ @@ -396,11 +396,11 @@ export const KEYBOARD_SHORTCUTS = { msgShowEmojiPicker: defineMessages({ default: { id: 'shortcuts.msgs.markdown.emoji', - defaultMessage: 'Emoji / Gif picker:\tCtrl|Shift|E', + defaultMessage: 'Emoji / Gif picker:\tCtrl|Alt|E', }, mac: { id: 'shortcuts.msgs.markdown.emoji.mac', - defaultMessage: 'Emoji / Gif picker:\t⌘|Shift|E', + defaultMessage: 'Emoji / Gif picker:\t⌘|⌥|E', }, }), msgMarkdownPreview: defineMessages({ @@ -410,7 +410,7 @@ export const KEYBOARD_SHORTCUTS = { }, mac: { id: 'shortcuts.msgs.markdown.preview.mac', - defaultMessage: 'Show/Hide Preview:\t⌘|Shift|P', + defaultMessage: 'Show/Hide Preview:\t⌘|⌥|P', }, }), msgMarkdownItalic: defineMessages({ diff --git a/webapp/channels/src/components/latex_block/latex_block.tsx b/webapp/channels/src/components/latex_block/latex_block.tsx index 836f70ee74e..f1cd8fd5025 100644 --- a/webapp/channels/src/components/latex_block/latex_block.tsx +++ b/webapp/channels/src/components/latex_block/latex_block.tsx @@ -62,7 +62,7 @@ const LatexBlock = ({ > ); diff --git a/webapp/channels/src/components/latex_inline/latex_inline.tsx b/webapp/channels/src/components/latex_inline/latex_inline.tsx index 7ed814a49bb..305aa8d742e 100644 --- a/webapp/channels/src/components/latex_inline/latex_inline.tsx +++ b/webapp/channels/src/components/latex_inline/latex_inline.tsx @@ -58,7 +58,7 @@ const LatexInline = ({content, enableInlineLatex}: Props) => { > ); diff --git a/webapp/channels/src/components/learn_more_trial_modal/learn_more_trial_modal.tsx b/webapp/channels/src/components/learn_more_trial_modal/learn_more_trial_modal.tsx index 74e310d6d50..3a952120c6a 100644 --- a/webapp/channels/src/components/learn_more_trial_modal/learn_more_trial_modal.tsx +++ b/webapp/channels/src/components/learn_more_trial_modal/learn_more_trial_modal.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useMemo} from 'react'; import {useIntl} from 'react-intl'; import {useSelector, useDispatch} from 'react-redux'; @@ -34,17 +34,12 @@ const LearnMoreTrialModal = ( onExited, }: Props): JSX.Element | null => { const {formatMessage} = useIntl(); - const [embargoed, setEmbargoed] = useState(false); const dispatch = useDispatch(); // Cloud conditions const license = useSelector(getLicense); const isCloud = license?.Cloud === 'true'; - const handleEmbargoError = useCallback(() => { - setEmbargoed(true); - }, []); - // close this modal once start trial btn is clicked and trial has started successfully const dismissAction = useCallback(() => { dispatch(closeModal(ModalIdentifiers.LEARN_MORE_TRIAL_MODAL)); @@ -52,7 +47,6 @@ const LearnMoreTrialModal = ( const startTrialBtn = ( ); @@ -143,7 +137,6 @@ const LearnMoreTrialModal = ( dataSlides={getSlides} id={'learnMoreTrialModalCarousel'} infiniteSlide={false} - disableNextButton={embargoed} btnsStyle={BtnStyle.CHEVRON} actionButton={startTrialBtn} /> diff --git a/webapp/channels/src/components/learn_more_trial_modal/start_trial_btn.tsx b/webapp/channels/src/components/learn_more_trial_modal/start_trial_btn.tsx index fa4f86a4772..851bd9f75bd 100644 --- a/webapp/channels/src/components/learn_more_trial_modal/start_trial_btn.tsx +++ b/webapp/channels/src/components/learn_more_trial_modal/start_trial_btn.tsx @@ -10,7 +10,6 @@ import './start_trial_btn.scss'; export type StartTrialBtnProps = { onClick?: () => void; - handleEmbargoError?: () => void; btnClass?: string; renderAsButton?: boolean; disabled?: boolean; diff --git a/webapp/channels/src/components/leave_team_modal/leave_team_modal.tsx b/webapp/channels/src/components/leave_team_modal/leave_team_modal.tsx index ebacb84f66a..50f4dd8cfb3 100644 --- a/webapp/channels/src/components/leave_team_modal/leave_team_modal.tsx +++ b/webapp/channels/src/components/leave_team_modal/leave_team_modal.tsx @@ -83,7 +83,7 @@ export default class LeaveTeamModal extends React.PureComponent { modalMessage = ( { modalMessage = ( {chunks}, @@ -106,7 +106,7 @@ export default class LeaveTeamModal extends React.PureComponent { modalMessage = ( {chunks}, @@ -118,7 +118,7 @@ export default class LeaveTeamModal extends React.PureComponent { modalMessage = ( { modalMessage = ( {chunks}, diff --git a/webapp/channels/src/components/linking_landing_page/index.tsx b/webapp/channels/src/components/linking_landing_page/index.tsx index 44c77611978..88092d4ce77 100644 --- a/webapp/channels/src/components/linking_landing_page/index.tsx +++ b/webapp/channels/src/components/linking_landing_page/index.tsx @@ -5,7 +5,6 @@ import {connect} from 'react-redux'; import {Client4} from 'mattermost-redux/client'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; -import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; import type {GlobalState} from 'types/store'; @@ -18,7 +17,6 @@ function mapStateToProps(state: GlobalState) { desktopAppLink: config.AppDownloadLink, iosAppLink: config.IosAppDownloadLink, androidAppLink: config.AndroidAppDownloadLink, - defaultTheme: getTheme(state), siteUrl: config.SiteURL, siteName: config.SiteName, brandImageUrl: Client4.getBrandImageUrl('0'), diff --git a/webapp/channels/src/components/linking_landing_page/linking_landing_page.tsx b/webapp/channels/src/components/linking_landing_page/linking_landing_page.tsx index 99cbae3a07d..f08b95ebdcd 100644 --- a/webapp/channels/src/components/linking_landing_page/linking_landing_page.tsx +++ b/webapp/channels/src/components/linking_landing_page/linking_landing_page.tsx @@ -13,10 +13,8 @@ import mobileImg from 'images/deep-linking/deeplinking-mobile-img.png'; import MattermostLogoSvg from 'images/logo.svg'; import {LandingPreferenceTypes} from 'utils/constants'; import * as UserAgent from 'utils/user_agent'; -import * as Utils from 'utils/utils'; type Props = { - defaultTheme: any; desktopAppLink?: string; iosAppLink?: string; androidAppLink?: string; @@ -89,7 +87,6 @@ export default class LinkingLandingPage extends PureComponent { } componentDidMount() { - Utils.applyTheme(this.props.defaultTheme); if (this.checkLandingPreferenceApp()) { this.openMattermostApp(); } diff --git a/webapp/channels/src/components/login/__snapshots__/login_mfa.test.tsx.snap b/webapp/channels/src/components/login/__snapshots__/login_mfa.test.tsx.snap index e24bd985b0c..0ba11e87079 100644 --- a/webapp/channels/src/components/login/__snapshots__/login_mfa.test.tsx.snap +++ b/webapp/channels/src/components/login/__snapshots__/login_mfa.test.tsx.snap @@ -14,7 +14,7 @@ exports[`components/login/LoginMfa should match snapshot 1`] = ` inputSize="large" name="token" onChange={[Function]} - placeholder="Enter MFA Token" + placeholder="MFA Token" type="text" value="" /> diff --git a/webapp/channels/src/components/login/login.test.tsx b/webapp/channels/src/components/login/login.test.tsx index b91d4cd18c9..c8b8bba03af 100644 --- a/webapp/channels/src/components/login/login.test.tsx +++ b/webapp/channels/src/components/login/login.test.tsx @@ -318,10 +318,8 @@ describe('components/login/Login', () => { const button = screen.getByRole('link', {name: 'Gitlab Icon GitLab 2'}); - expect(button.style).toMatchObject({ - color: 'rgb(0, 255, 0)', - borderColor: '#00ff00', - }); + expect(button.style.color).toBe('rgb(0, 255, 0)'); + expect(button.style.borderColor).toBe('rgb(0, 255, 0)'); }); it('should focus username field when there is an error', async () => { @@ -374,10 +372,8 @@ describe('components/login/Login', () => { const button = screen.getByRole('link', {name: 'OpenID Icon OpenID 2'}); - expect(button.style).toMatchObject({ - color: 'rgb(0, 255, 0)', - borderColor: '#00ff00', - }); + expect(button.style.color).toBe('rgb(0, 255, 0)'); + expect(button.style.borderColor).toBe('rgb(0, 255, 0)'); }); it('should redirect on login', async () => { diff --git a/webapp/channels/src/components/login/login.tsx b/webapp/channels/src/components/login/login.tsx index 54477b96c89..f44c9185e01 100644 --- a/webapp/channels/src/components/login/login.tsx +++ b/webapp/channels/src/components/login/login.tsx @@ -48,7 +48,6 @@ import PasswordInput from 'components/widgets/inputs/password_input/password_inp import Constants from 'utils/constants'; import DesktopApp from 'utils/desktop_api'; import {isEmbedded} from 'utils/embed'; -import {t} from 'utils/i18n'; import {DesktopNotificationSounds} from 'utils/notification_sounds'; import {showNotification} from 'utils/notifications'; import {isDesktopApp} from 'utils/user_agent'; @@ -372,7 +371,7 @@ const Login = ({onCustomizeHeader}: LoginProps) => { mode = 'danger'; title = formatMessage({ id: 'login.get_terms_error', - defaultMessage: 'Unable to load terms of service. If this issue persists, contact your System Administrator.', + defaultMessage: 'Unable to load terms of use. If this issue persists, contact your System Administrator.', }); break; @@ -610,33 +609,42 @@ const Login = ({onCustomizeHeader}: LoginProps) => { currentLoginId = currentLoginId.trim().toLowerCase(); if (!currentLoginId) { - t('login.noEmail'); - t('login.noEmailLdapUsername'); - t('login.noEmailUsername'); - t('login.noEmailUsernameLdapUsername'); - t('login.noLdapUsername'); - t('login.noUsername'); - t('login.noUsernameLdapUsername'); + const ldapUsername = LdapLoginFieldName || formatMessage({id: 'login.ldapUsernameLower', defaultMessage: 'AD/LDAP username'}); - // it's slightly weird to be constructing the message ID, but it's a bit nicer than triply nested if statements - let msgId = 'login.no'; - if (enableSignInWithEmail) { - msgId += 'Email'; - } - if (enableSignInWithUsername) { - msgId += 'Username'; - } - if (ldapEnabled) { - msgId += 'LdapUsername'; + let title; + + // 3 methods, 2 methods, 1 method - Keep in mind order of cases. + switch (true) { + // three login methods enabled + case enableSignInWithEmail && enableSignInWithUsername && ldapEnabled: + title = formatMessage({id: 'login.noEmailUsernameLdapUsername', defaultMessage: 'Please enter your email, username or {ldapUsername}'}, {ldapUsername}); + break; + + // two login methods enabled + case enableSignInWithEmail && enableSignInWithUsername: + title = formatMessage({id: 'login.noEmailUsername', defaultMessage: 'Please enter your email or username'}); + break; + case enableSignInWithEmail && ldapEnabled: + title = formatMessage({id: 'login.noEmailLdapUsername', defaultMessage: 'Please enter your email or {ldapUsername}'}, {ldapUsername}); + break; + case enableSignInWithUsername && ldapEnabled: + title = formatMessage({id: 'login.noUsernameLdapUsername', defaultMessage: 'Please enter your username or {ldapUsername}'}, {ldapUsername}); + break; + + // one login method enabled + case enableSignInWithEmail: + title = formatMessage({id: 'login.noEmail', defaultMessage: 'Please enter your email'}); + break; + case ldapEnabled: + title = formatMessage({id: 'login.noLdapUsername', defaultMessage: 'Please enter your {ldapUsername}'}, {ldapUsername}); + break; + case enableSignInWithUsername: + default: + title = formatMessage({id: 'login.noUsername', defaultMessage: 'Please enter your username'}); + break; } - setAlertBanner({ - mode: 'danger', - title: formatMessage( - {id: msgId}, - {ldapUsername: LdapLoginFieldName || formatMessage({id: 'login.ldapUsernameLower', defaultMessage: 'AD/LDAP username'})}, - ), - }); + setAlertBanner({mode: 'danger', title}); setHasError(true); setIsWaiting(false); diff --git a/webapp/channels/src/components/login/login_mfa.tsx b/webapp/channels/src/components/login/login_mfa.tsx index bd2a4906ea9..a8aa61d51cb 100644 --- a/webapp/channels/src/components/login/login_mfa.tsx +++ b/webapp/channels/src/components/login/login_mfa.tsx @@ -5,7 +5,7 @@ import React, {type ReactNode, useState} from 'react'; import {useIntl} from 'react-intl'; import type {SubmitOptions} from 'components/claim/components/email_to_ldap'; -import ShieldWithCheckmarkSVG from 'components/common/svg_images_components/shield_with_checkmark'; +import ShieldWithCheckmarkSVG from 'components/common/svg_images_components/shield_with_checkmark_svg'; import ColumnLayout from 'components/header_footer_route/content_layouts/column'; import SaveButton from 'components/save_button'; import Input, {SIZE} from 'components/widgets/inputs/input/input'; @@ -60,7 +60,7 @@ const LoginMfa = ({loginId, password, title, subtitle, onSubmit}: LoginMfaProps) inputSize={SIZE.LARGE} value={token} onChange={handleInputOnChange} - placeholder={formatMessage({id: 'login_mfa.token', defaultMessage: 'Enter MFA Token'})} + placeholder={formatMessage({id: 'login_mfa.token', defaultMessage: 'MFA Token'})} autoFocus={true} disabled={saving} /> diff --git a/webapp/channels/src/components/mfa/mfa_controller/mfa_controller.tsx b/webapp/channels/src/components/mfa/mfa_controller/mfa_controller.tsx index 68f3330088f..c7728f6b92c 100644 --- a/webapp/channels/src/components/mfa/mfa_controller/mfa_controller.tsx +++ b/webapp/channels/src/components/mfa/mfa_controller/mfa_controller.tsx @@ -35,17 +35,7 @@ type Props = { }; } -type State = { - enforceMultifactorAuthentication: boolean; -} - -export default class MFAController extends React.PureComponent { - public constructor(props: Props & RouteComponentProps) { - super(props); - - this.state = {enforceMultifactorAuthentication: props.enableMultifactorAuthentication}; - } - +export default class MFAController extends React.PureComponent { public componentDidMount(): void { document.body.classList.add('sticky'); document.getElementById('root')!.classList.add('container-fluid'); @@ -65,10 +55,6 @@ export default class MFAController extends React.PureComponent { - this.setState(state); - }; - public render(): JSX.Element { let backButton; if (this.props.mfa && this.props.enforceMultifactorAuthentication) { @@ -114,8 +100,6 @@ export default class MFAController extends React.PureComponent ( )} diff --git a/webapp/channels/src/components/mfa/setup/__snapshots__/setup.test.tsx.snap b/webapp/channels/src/components/mfa/setup/__snapshots__/setup.test.tsx.snap index 756f88f38cc..3955bca306b 100644 --- a/webapp/channels/src/components/mfa/setup/__snapshots__/setup.test.tsx.snap +++ b/webapp/channels/src/components/mfa/setup/__snapshots__/setup.test.tsx.snap @@ -57,11 +57,6 @@ exports[`components/mfa/setup should match snapshot without required text 1`] =

diff --git a/webapp/channels/src/components/mfa/setup/setup.tsx b/webapp/channels/src/components/mfa/setup/setup.tsx index 8055263a3af..d6bef6ac24b 100644 --- a/webapp/channels/src/components/mfa/setup/setup.tsx +++ b/webapp/channels/src/components/mfa/setup/setup.tsx @@ -8,22 +8,7 @@ import type {UserProfile} from '@mattermost/types/users'; import LocalizedPlaceholderInput from 'components/localized_placeholder_input'; -type MFAControllerState = { - enforceMultifactorAuthentication: boolean; -}; - type Props = { - - /* - * Object containing enforceMultifactorAuthentication - */ - state: MFAControllerState; - - /* - * Function that updates parent component with state props - */ - updateParent: (state: MFAControllerState) => void; - currentUser: UserProfile; siteName?: string; enforceMultifactorAuthentication: boolean; @@ -211,9 +196,6 @@ export default class Setup extends React.PureComponent { {chunks}, - }} />

diff --git a/webapp/channels/src/components/mobile_sidebar_right/mobile_sidebar_right.scss b/webapp/channels/src/components/mobile_sidebar_right/mobile_sidebar_right.scss index 86a52f09111..824ec6a1254 100644 --- a/webapp/channels/src/components/mobile_sidebar_right/mobile_sidebar_right.scss +++ b/webapp/channels/src/components/mobile_sidebar_right/mobile_sidebar_right.scss @@ -19,6 +19,10 @@ transform: translate3d(0, 0, 0); } + .a11y--focused { + box-shadow: rgba(56, 111, 229, 0.5) 0px 0px 1px 3px, rgb(56, 111, 229) 0px 0px 0px 1px !important; + } + .mentions { font-size: 17px; font-weight: bold; diff --git a/webapp/channels/src/components/mobile_sidebar_right/mobile_sidebar_right.tsx b/webapp/channels/src/components/mobile_sidebar_right/mobile_sidebar_right.tsx index 52bbd6f4c74..c7665ee0fd5 100644 --- a/webapp/channels/src/components/mobile_sidebar_right/mobile_sidebar_right.tsx +++ b/webapp/channels/src/components/mobile_sidebar_right/mobile_sidebar_right.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import classNames from 'classnames'; -import React, {memo} from 'react'; +import React, {memo, useEffect, useRef} from 'react'; import {CSSTransition} from 'react-transition-group'; import useGetUsageDeltas from 'components/common/hooks/useGetUsageDeltas'; @@ -21,6 +21,13 @@ const MobileRightDrawer = ({ currentUser, }: Props) => { const usageDeltas = useGetUsageDeltas(); + const sidebarRef = useRef(null); + + useEffect(() => { + if (isOpen && sidebarRef.current) { + sidebarRef.current.focus(); + } + }, [isOpen]); if (!currentUser) { return null; @@ -28,8 +35,13 @@ const MobileRightDrawer = ({ return (

; +} as const; -export const RECENT_EMOJI_CATEGORY: Pick = {recent: emojiCategories.recent}; -export const SEARCH_EMOJI_CATEGORY: Pick = {searchResults: emojiCategories.searchResults}; +const {recent, searchResults, ...standardCategories} = EMOJI_CATEGORIES; -export const CATEGORIES: Categories = Emoji.CategoryNames. - filter((category) => !(category === 'recent' || category === 'searchResults')). - reduce((previousCategory, currentCategory) => { - return { - ...previousCategory, - [currentCategory]: emojiCategories[currentCategory as EmojiCategory], - }; - }, {} as Categories); +export const RECENT_EMOJI_CATEGORY: Pick = {recent}; +export const SEARCH_EMOJI_CATEGORY: Pick = {searchResults}; + +// TODO CATEGORIES doesn't contain 'recent' or 'searchResults' as it's type claims +export const CATEGORIES = standardCategories as Categories; export const EMOJI_PER_ROW = 9; // needs to match variable `$emoji-per-row` in _variables.scss export const ITEM_HEIGHT = 36; //as per .emoji-picker__item height in _emoticons.scss diff --git a/webapp/channels/src/components/emoji_picker/emoji_picker.test.tsx b/webapp/channels/src/components/emoji_picker/emoji_picker.test.tsx index eb3c6fcc9c3..86fe9a42972 100644 --- a/webapp/channels/src/components/emoji_picker/emoji_picker.test.tsx +++ b/webapp/channels/src/components/emoji_picker/emoji_picker.test.tsx @@ -64,7 +64,7 @@ describe('components/emoji_picker/EmojiPicker', () => { , ); - expect(screen.queryByLabelText('Recent')).not.toBeNull(); + expect(screen.queryByLabelText('Recently Used')).not.toBeNull(); }); test('First emoji should be selected on search', () => { diff --git a/webapp/channels/src/components/emoji_picker/types/index.ts b/webapp/channels/src/components/emoji_picker/types/index.ts index 6be32984c80..bfe120e4690 100644 --- a/webapp/channels/src/components/emoji_picker/types/index.ts +++ b/webapp/channels/src/components/emoji_picker/types/index.ts @@ -18,6 +18,7 @@ export type Category = { }; export type Categories = Record; +export type ListedCategories = Omit & Partial>; export type CategoryOrEmojiRow = CategoryHeaderRow | EmojiRow; diff --git a/webapp/channels/src/components/feature_restricted_modal/feature_restricted_modal.tsx b/webapp/channels/src/components/feature_restricted_modal/feature_restricted_modal.tsx index b2c0ae4f3c2..19f2e4938cf 100644 --- a/webapp/channels/src/components/feature_restricted_modal/feature_restricted_modal.tsx +++ b/webapp/channels/src/components/feature_restricted_modal/feature_restricted_modal.tsx @@ -71,7 +71,7 @@ const FeatureRestrictedModal = ({ const [notifyAdminBtnText, notifyAdmin, notifyRequestStatus] = useNotifyAdmin({ ctaText: formatMessage({ id: 'feature_restricted_modal.button.notify', - defaultMessage: 'Notify Admin', + defaultMessage: 'Notify admin', }), }, { required_feature: feature || '', @@ -152,7 +152,7 @@ const FeatureRestrictedModal = ({

( diff --git a/webapp/channels/src/components/file_attachment/__snapshots__/file_attachment.test.tsx.snap b/webapp/channels/src/components/file_attachment/__snapshots__/file_attachment.test.tsx.snap index f9faae29bc4..19b9dbc572c 100644 --- a/webapp/channels/src/components/file_attachment/__snapshots__/file_attachment.test.tsx.snap +++ b/webapp/channels/src/components/file_attachment/__snapshots__/file_attachment.test.tsx.snap @@ -136,7 +136,7 @@ exports[`FileAttachment should match snapshot, after change from file to image 1 >

`; +exports[`FileThumbnail should render an icon for a PSD (MM-67077) 1`] = ` +
+`; + exports[`FileThumbnail should render an icon for an SVG when SVG previews are disabled 1`] = `
{ expect(wrapper).toMatchSnapshot(); expect(wrapper.find('div.file-icon').exists()).toBe(true); }); + + test('should render an icon for a PSD (MM-67077)', () => { + const props = { + ...baseProps, + fileInfo: { + ...fileInfo, + extension: 'psd', + }, + }; + + const wrapper = shallow( + , + ); + + expect(wrapper).toMatchSnapshot(); + expect(wrapper.find('div.file-icon').exists()).toBe(true); + }); }); diff --git a/webapp/channels/src/components/file_preview_modal/file_preview_modal.tsx b/webapp/channels/src/components/file_preview_modal/file_preview_modal.tsx index 56571563585..f4d398ab63c 100644 --- a/webapp/channels/src/components/file_preview_modal/file_preview_modal.tsx +++ b/webapp/channels/src/components/file_preview_modal/file_preview_modal.tsx @@ -50,11 +50,6 @@ export type Props = { pluginFilePreviewComponents: FilePreviewComponent[]; onExited: () => void; - /** - * The id of the post the files are attached to - */ - postId?: string; - /** * The post the files are attached to * Either postId or post can be passed to FilePreviewModal diff --git a/webapp/channels/src/components/flag_message_modal/flag_post_modal.test.tsx b/webapp/channels/src/components/flag_message_modal/flag_post_modal.test.tsx index a35e1568612..d5a576b280d 100644 --- a/webapp/channels/src/components/flag_message_modal/flag_post_modal.test.tsx +++ b/webapp/channels/src/components/flag_message_modal/flag_post_modal.test.tsx @@ -56,7 +56,7 @@ describe('components/FlagPostModal', () => { baseState, ); - await userEvent.click(screen.getByText('Select a reason for flagging')); + await userEvent.click(screen.getByText('Select a reason')); expect(screen.getByText('Reason 1')).toBeVisible(); expect(screen.getByText('Reason 2')).toBeVisible(); @@ -107,7 +107,7 @@ describe('components/FlagPostModal', () => { ); // Select a reason - await userEvent.click(screen.getByText('Select a reason for flagging')); + await userEvent.click(screen.getByText('Select a reason')); await userEvent.click(screen.getByText('Reason 1')); // Add a comment diff --git a/webapp/channels/src/components/flag_message_modal/flag_post_modal.tsx b/webapp/channels/src/components/flag_message_modal/flag_post_modal.tsx index c64ca3c343b..7e59dd57219 100644 --- a/webapp/channels/src/components/flag_message_modal/flag_post_modal.tsx +++ b/webapp/channels/src/components/flag_message_modal/flag_post_modal.tsx @@ -54,11 +54,11 @@ export default function FlagPostModal({postId, onExited}: Props) { const [showCommentPreview, setShowCommentPreview] = React.useState(false); const label = formatMessage({id: 'flag_message_modal.heading', defaultMessage: 'Flag message'}); - const subHeading = formatMessage({id: 'flag_message_modal.subheading', defaultMessage: 'Flagged messages will be sent to Content Reviewers for review'}); + const subHeading = formatMessage({id: 'flag_message_modal.subheading', defaultMessage: 'Flagged messages will be sent to Content Reviewers for review.'}); const submitButtonText = formatMessage({id: 'generic.submit', defaultMessage: 'Submit'}); const requiredCommentSectionTitle = formatMessage({id: 'flag_message_modal.required_comment.title', defaultMessage: 'Comment (required)'}); const optionalCommentSectionTitle = formatMessage({id: 'flag_message_modal.optional_comment.title', defaultMessage: 'Comment (optional)'}); - const reasonSelectPlaceholder = formatMessage({id: 'flag_message_modal.reason_select.placeholder', defaultMessage: 'Select a reason for flagging'}); + const reasonSelectPlaceholder = formatMessage({id: 'flag_message_modal.reason_select.placeholder', defaultMessage: 'Select a reason'}); const commentPlaceholder = formatMessage({id: 'flag_message_modal.comment.placeholder', defaultMessage: 'Describe your concern...'}); const post = useSelector((state: GlobalState) => getPost(state, postId)); diff --git a/webapp/channels/src/components/forward_post_modal/forward_post_channel_select.tsx b/webapp/channels/src/components/forward_post_modal/forward_post_channel_select.tsx index 4f63f954765..2f3d5ebf0ef 100644 --- a/webapp/channels/src/components/forward_post_modal/forward_post_channel_select.tsx +++ b/webapp/channels/src/components/forward_post_modal/forward_post_channel_select.tsx @@ -9,7 +9,7 @@ import type {OptionProps, SingleValueProps, OnChangeValue, DropdownIndicatorProp import AsyncSelect from 'react-select/async'; import { - ArchiveOutlineIcon, ChevronDownIcon, + ChevronDownIcon, GlobeIcon, LockOutlineIcon, MessageTextOutlineIcon, @@ -29,6 +29,7 @@ import SwitchChannelProvider from 'components/suggestion/switch_channel_provider import BotTag from 'components/widgets/tag/bot_tag'; import GuestTag from 'components/widgets/tag/guest_tag'; +import {getArchiveIconComponent} from 'utils/channel_utils'; import Constants from 'utils/constants'; import * as Utils from 'utils/utils'; @@ -74,7 +75,8 @@ const FormattedOption = (props: ChannelOption & {className: string; isSingleValu }; if (channelIsArchived) { - icon = ; + const ArchiveIcon = getArchiveIconComponent(details.type); + icon = ; } else if (details.type === Constants.OPEN_CHANNEL) { icon = ; } else if (details.type === Constants.PRIVATE_CHANNEL) { diff --git a/webapp/channels/src/components/forward_post_modal/index.tsx b/webapp/channels/src/components/forward_post_modal/index.tsx index b0fc926e1f4..d2102c0207d 100644 --- a/webapp/channels/src/components/forward_post_modal/index.tsx +++ b/webapp/channels/src/components/forward_post_modal/index.tsx @@ -162,7 +162,6 @@ const ForwardPostModal = ({onExited, post}: Props) => { defaultMessage='This message is from a private conversation and can only be shared with {participants}' values={{ participants: , - strong: (x: React.ReactNode) => {x}, }} /> ); diff --git a/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/__snapshots__/user_guide_dropdown.test.tsx.snap b/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/__snapshots__/user_guide_dropdown.test.tsx.snap index 0615a9b2da8..765b96b8ad3 100644 --- a/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/__snapshots__/user_guide_dropdown.test.tsx.snap +++ b/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/__snapshots__/user_guide_dropdown.test.tsx.snap @@ -25,7 +25,7 @@ exports[`components/channel_header/components/UserGuideDropdown should match sna /> { openLeft={false} openUp={false} id='AddChannelDropdown' - ariaLabel={intl.formatMessage({id: 'sidebar_left.add_channel_dropdown.dropdownAriaLabel', defaultMessage: 'Add Channel Dropdown'})} + ariaLabel={intl.formatMessage({id: 'channel_header.userHelpGuide', defaultMessage: 'Help'})} > {this.renderDropdownItems()} diff --git a/webapp/channels/src/components/initial_loading_screen/initial_loading_screen.css b/webapp/channels/src/components/initial_loading_screen/initial_loading_screen.css index 2d3ae5af1a9..7f6ac955e57 100644 --- a/webapp/channels/src/components/initial_loading_screen/initial_loading_screen.css +++ b/webapp/channels/src/components/initial_loading_screen/initial_loading_screen.css @@ -7,10 +7,11 @@ body { } .LoadingScreen { - --background-color: #f4f4f6; - --background-color-highlight: #ffffff; - --stipple-color: #1e325c; - --stipple-opacity: 0.08; + --background-color: #ffffff; + --background-gradient: linear-gradient(152deg, rgba(63, 67, 80, 0.00) 0%, rgba(63, 67, 80, 0.04) 80%, #FFFFFF); + --background-texture: rgba(63, 67, 80, 0.04); + --gradient-center-color: #FFF; + --gradient-edge-color: rgba(255, 255, 255, 0); position: absolute; z-index: 100; top: 0px; @@ -25,13 +26,25 @@ body { transition: opacity 150ms 0ms ease-out, visibility 150ms 0ms step-start; vertical-align: middle; visibility: visible; + + .LoadingScreen__background-gradient { + background-image: var(--background-gradient); + } + + .LoadingScreen__background-texture { + fill: var(--background-texture); + } } -.LoadingScreen--darkMode { - --background-color-highlight: #28427b; - --background-color: #1e325c; - --stipple-color: #14213e; - --stipple-opacity: 0.8; +/* Apply dark mode based on OS preference automatically */ +@media (prefers-color-scheme: dark) { + .LoadingScreen { + --background-color: #191B1F; + --background-gradient: linear-gradient(152deg, rgba(221, 223, 228, 0.00) 0%, rgba(221, 223, 228, 0.04) 80, #191B1F); + --background-texture: rgba(221, 223, 228, 0.04); + --gradient-center-color: #1B1D22; + --gradient-edge-color: rgba(27, 29, 34, 0); + } } .LoadingScreen__background, @@ -46,22 +59,9 @@ body { background: var(--background-color); } -.LoadingScreen .Pill__stipple { - fill: var(--stipple-color); - fill-opacity: var(--stipple-opacity); -} - -.LoadingScreen .Pill__gradient { - stop-color: var(--background-color); -} - -.LoadingScreen .Pill__gradientHighlight { - stop-color: var(--background-color-highlight); -} - .LoadingScreen--loaded { opacity: 0; - transition: opacity 150ms 0ms ease-in, visibility 150ms 0ms step-end; + transition: opacity 400ms 0ms ease-in, visibility 400ms 0ms step-end; visibility: hidden; } @@ -83,8 +83,11 @@ body { transform: scale3d(1, 1, 1); } -.LoadingAnimation--darkMode { - --colour: white; +/* Apply dark mode based on OS preference automatically */ +@media (prefers-color-scheme: dark) { + .LoadingAnimation { + --colour: white; + } } .LoadingAnimation g, @@ -208,7 +211,7 @@ body { } .LoadingAnimation--loaded { - --duration: 150ms; + --duration: 400ms; --delay: 0ms; animation: LoadingAnimation__fade-out var(--duration) var(--delay) var(--ease-in) forwards, LoadingAnimation__shrink var(--duration) var(--delay) var(--ease-in) forwards; diff --git a/webapp/channels/src/components/initial_loading_screen/initial_loading_screen_class.ts b/webapp/channels/src/components/initial_loading_screen/initial_loading_screen_class.ts index 4be5d586bf6..b437776e55b 100644 --- a/webapp/channels/src/components/initial_loading_screen/initial_loading_screen_class.ts +++ b/webapp/channels/src/components/initial_loading_screen/initial_loading_screen_class.ts @@ -7,14 +7,14 @@ import {isDesktopApp} from 'utils/user_agent'; const ANIMATION_CLASS_FOR_MATTERMOST_LOGO_HIDE = 'LoadingAnimation__compass-shrink'; const ANIMATION_CLASS_FOR_COMPLETE_LOADER_HIDE = 'LoadingAnimation__shrink'; -const LOADING_CLASS_FOR_SCREEN = 'LoadingScreen LoadingScreen--darkMode'; -const LOADING_COMPLETE_CLASS_FOR_SCREEN = 'LoadingScreen LoadingScreen--darkMode LoadingScreen--loaded'; - -const STATIC_CLASS_FOR_ANIMATION = 'LoadingAnimation LoadingAnimation--darkMode'; -const LOADING_CLASS_FOR_ANIMATION = STATIC_CLASS_FOR_ANIMATION + ' LoadingAnimation--spinning LoadingAnimation--loading'; -const LOADING_COMPLETE_CLASS_FOR_ANIMATION = STATIC_CLASS_FOR_ANIMATION + ' LoadingAnimation--spinning LoadingAnimation--loaded'; - const DESTROY_DELAY_AFTER_ANIMATION_END = 1000; +const MINIMUM_LOADING_TIME = 2000; // Minimum time to show the loading screen (in ms) + +const LOADING_CLASS_FOR_SCREEN = 'LoadingScreen'; +const LOADING_COMPLETE_CLASS_FOR_SCREEN = 'LoadingScreen LoadingScreen--loaded'; +const STATIC_CLASS_FOR_ANIMATION = 'LoadingAnimation'; +const LOADING_CLASS_FOR_ANIMATION = 'LoadingAnimation LoadingAnimation--spinning LoadingAnimation--loading'; +const LOADING_COMPLETE_CLASS_FOR_ANIMATION = 'LoadingAnimation LoadingAnimation--spinning LoadingAnimation--loaded'; export class InitialLoadingScreenClass { private isLoading: boolean | null = true; @@ -23,6 +23,7 @@ export class InitialLoadingScreenClass { private loadingAnimationElement: HTMLElement | null; private initialLoadingScreenCSS: HTMLLinkElement | null; + private startTime: number | null = null; constructor() { this.loadingScreenElement = document.getElementById('initialPageLoadingScreen'); @@ -111,6 +112,7 @@ export class InitialLoadingScreenClass { } this.isLoading = true; + this.startTime = Date.now(); this.loadingScreenElement.className = LOADING_CLASS_FOR_SCREEN; this.loadingAnimationElement.className = LOADING_CLASS_FOR_ANIMATION; @@ -121,19 +123,30 @@ export class InitialLoadingScreenClass { return; } - this.isLoading = false; + // Calculate how long the loading screen has been visible + const elapsedTime = this.startTime ? Date.now() - this.startTime : 0; + const remainingTime = Math.max(0, MINIMUM_LOADING_TIME - elapsedTime); - this.loadingScreenElement.className = LOADING_COMPLETE_CLASS_FOR_SCREEN; - this.loadingAnimationElement.className = LOADING_COMPLETE_CLASS_FOR_ANIMATION; + // If minimum time hasn't elapsed, delay the stop + setTimeout(() => { + if (!this.loadingScreenElement || !this.loadingAnimationElement) { + return; + } - measureAndReport({ - name: Measure.SplashScreen, - startMark: 0, - canFail: false, - labels: { - page_type: pageType, - }, - }); + this.isLoading = false; + + this.loadingScreenElement.className = LOADING_COMPLETE_CLASS_FOR_SCREEN; + this.loadingAnimationElement.className = LOADING_COMPLETE_CLASS_FOR_ANIMATION; + + measureAndReport({ + name: Measure.SplashScreen, + startMark: 0, + canFail: false, + labels: { + page_type: pageType, + }, + }); + }, remainingTime); } } diff --git a/webapp/channels/src/components/initial_loading_screen/initial_loading_screen_template.html b/webapp/channels/src/components/initial_loading_screen/initial_loading_screen_template.html index be72103f759..7ccc677a493 100644 --- a/webapp/channels/src/components/initial_loading_screen/initial_loading_screen_template.html +++ b/webapp/channels/src/components/initial_loading_screen/initial_loading_screen_template.html @@ -1,90 +1,4686 @@
- - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
@@ -765,7 +765,7 @@ exports[`components/integrations/AbstractCommand should match snapshot when head className="form__help" >
@@ -1243,7 +1243,7 @@ exports[`components/integrations/AbstractCommand should match snapshot, displays className="form__help" >
diff --git a/webapp/channels/src/components/integrations/__snapshots__/abstract_incoming_hook.test.tsx.snap b/webapp/channels/src/components/integrations/__snapshots__/abstract_incoming_hook.test.tsx.snap index 4adfbfe6b3e..09935c5917a 100644 --- a/webapp/channels/src/components/integrations/__snapshots__/abstract_incoming_hook.test.tsx.snap +++ b/webapp/channels/src/components/integrations/__snapshots__/abstract_incoming_hook.test.tsx.snap @@ -181,7 +181,7 @@ exports[`components/integrations/AbstractIncomingWebhook should call action func className="form__help" >
@@ -446,7 +446,7 @@ exports[`components/integrations/AbstractIncomingWebhook should match snapshot 1 className="form__help" >
@@ -711,7 +711,7 @@ exports[`components/integrations/AbstractIncomingWebhook should match snapshot w className="form__help" >
@@ -976,7 +976,7 @@ exports[`components/integrations/AbstractIncomingWebhook should match snapshot, className="form__help" >
@@ -1244,7 +1244,7 @@ exports[`components/integrations/AbstractIncomingWebhook should match snapshot, className="form__help" >