diff --git a/.github/workflows/channels-ci.yml b/.github/workflows/channels-ci.yml index d6a1a6258c..7d71862ba7 100644 --- a/.github/workflows/channels-ci.yml +++ b/.github/workflows/channels-ci.yml @@ -83,6 +83,16 @@ jobs: 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 + - name: ci/lint-boards + working-directory: webapp/boards + run: | + npm run i18n-extract + git --no-pager diff --exit-code i18n/en.json || (echo "Please run \"cd webapp/boards && npm run i18n-extract\" and commit the changes in webapp/boards/i18n/en.json." && exit 1) + - name: ci/lint-playbooks + working-directory: webapp/playbooks + run: | + npm run i18n-extract + git --no-pager diff --exit-code i18n/en.json || (echo "Please run \"cd webapp/playbooks && npm run i18n-extract\" and commit the changes in webapp/playbooks/i18n/en.json." && exit 1) check-types: runs-on: ubuntu-22.04 defaults: diff --git a/CODEOWNERS b/CODEOWNERS index b0bd218122..4ef5c97c43 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -5,3 +5,6 @@ /webapp/package-lock.json @mattermost/web-platform /webapp/platform/*/package.json @mattermost/web-platform /webapp/scripts @mattermost/web-platform +/server/channels/db/migrations @mattermost/server-platform +/server/boards/services/store/sqlstore/migrations @mattermost/server-platform +/server/playbooks/server/sqlstore/migrations @mattermost/server-platform diff --git a/e2e-tests/cypress/tests/integration/boards/create_board_spec.ts b/e2e-tests/cypress/tests/integration/boards/create_board_spec.ts index 17ed47c57b..0d0e0b2ad8 100644 --- a/e2e-tests/cypress/tests/integration/boards/create_board_spec.ts +++ b/e2e-tests/cypress/tests/integration/boards/create_board_spec.ts @@ -98,6 +98,72 @@ describe('Create and delete board / card', () => { cy.findByText('for testing purposes only').should('be.visible'); }); + it('MM-T4276 Set up Board emoji', () => { + cy.visit('/boards'); + + // # Create an empty board and change tile to Testing + cy.findByText('Create an empty board').should('exist').click({force: true}); + cy.get('.BoardComponent').should('exist'); + + // # Change Title + cy.findByPlaceholderText('Untitled board').should('be.visible').wait(timeouts.HALF_SEC); + + // * Assert that the title is changed to "testing" + cy.findByPlaceholderText('Untitled board'). + clear(). + type('Testing'). + type('{enter}'). + should('have.value', 'Testing'); + + // # "Add icon" and "Show description" options appear + cy.findByText('Add icon').should('exist'); + cy.findByText('show description').should('exist'); + + // # Click on "Add icon" + cy.findByText('Add icon').should('exist').click({force: true}); + + // * Assert that a random emoji is selected and added at the beginning of the board title + cy.get('.IconSelector').should('exist'); + + // # Click on the emoji next to the board title + cy.get('.IconSelector .MenuWrapper').should('exist').click({force: true}); + + // * Assert that Dropdown menu with 3 options appears + cy.findByText('Random').should('exist'); + cy.findByText('Pick icon').should('exist'); + cy.findByText('Remove icon').should('exist'); + + // # Hover your mouse over the "Pick Icon" option + cy.findByText('Pick icon').trigger('mouseover'); + + // * Assert that emoji picker menu appears + cy.get('.IconSelector .menu-contents').should('exist'); + + // # Click on the emoji from the picker + cy.get('.EmojiPicker').should('exist').and('be.visible').within(() => { + // # Click on the emoji + cy.get("[aria-label='😀, grinning']").should('exist'); + cy.get("[aria-label='😀, grinning']").eq(0).click({force: true}); + }); + + // * Assert that Selected emoji is now displayed next to the board title + cy.get('.IconSelector span').contains('😀'); + + // # Click on the emoji next to the board title + cy.get('.IconSelector .MenuWrapper').should('exist').click({force: true}); + + // * Assert that Dropdown menu with 3 options appears + cy.findByText('Random').should('exist'); + cy.findByText('Pick icon').should('exist'); + cy.findByText('Remove icon').should('exist'); + + // # Click "Remove icon" + cy.findByText('Remove icon').click({force: true}); + + // * Assert that Icon next to the board title is removed + cy.get('.IconSelector').should('not.exist'); + }); + it('MM-T5397 Can create and delete a board and a card', () => { // Visit a page and create new empty board cy.visit('/boards'); diff --git a/e2e-tests/playwright/README.md b/e2e-tests/playwright/README.md index 25dd2c9d68..0a2a005243 100644 --- a/e2e-tests/playwright/README.md +++ b/e2e-tests/playwright/README.md @@ -2,8 +2,21 @@ #### 1. Start local server in a separate terminal. +``` +# Typically run the local server with: +cd server && make run + +# Or build and distribute webapp including channels, boards and playbooks +# so that their product URLs do not rely on Webpack dev server. +# Especially important when running test inside the Playwright's docker container. +cd webapp && make dist +cd server && make run-server +``` + #### 2. Install dependencies and run the test. +Note: If you're using Node.js version 18 and above, you may need to set `NODE_OPTIONS='--no-experimental-fetch'`. + ``` # Install npm packages npm i @@ -32,14 +45,16 @@ npm run test Change to root directory, run docker container ``` -docker run -it --rm -v "$(pwd):/mattermost/" --ipc=host mcr.microsoft.com/playwright:v1.30.0-focal /bin/bash +docker run -it --rm -v "$(pwd):/mattermost/" --ipc=host mcr.microsoft.com/playwright:v1.32.0-focal /bin/bash ``` #### 2. Inside the docker container ``` +export NODE_OPTIONS='--no-experimental-fetch' export PW_BASE_URL=http://host.docker.internal:8065 -cd mattermost/e2e/playwright +export PW_HEADLESS=true +cd mattermost/e2e-tests/playwright # Install npm packages. Use "npm ci" to match the automated environment npm ci diff --git a/e2e-tests/playwright/package-lock.json b/e2e-tests/playwright/package-lock.json index 6d5a200c81..ef8f199c95 100644 --- a/e2e-tests/playwright/package-lock.json +++ b/e2e-tests/playwright/package-lock.json @@ -6,30 +6,31 @@ "packages": { "": { "dependencies": { - "@percy/cli": "1.18.0", + "@percy/cli": "1.23.0", "@percy/playwright": "1.0.4", "@playwright/test": "1.32.3", "async-wait-until": "2.0.12", "chalk": "4.1.2", - "deepmerge": "4.3.0", + "deepmerge": "4.3.1", "dotenv": "16.0.3", "form-data": "4.0.0", "isomorphic-unfetch": "4.0.2", "uuid": "9.0.0" }, "devDependencies": { - "@types/uuid": "9.0.0", - "@typescript-eslint/eslint-plugin": "5.51.0", - "@typescript-eslint/parser": "5.51.0", - "eslint": "8.34.0", - "prettier": "2.8.4", - "typescript": "4.9.5" + "@types/uuid": "9.0.1", + "@typescript-eslint/eslint-plugin": "5.59.0", + "@typescript-eslint/parser": "5.59.0", + "cross-env": "7.0.3", + "eslint": "8.38.0", + "prettier": "2.8.7", + "typescript": "5.0.4" } }, "node_modules/@babel/code-frame": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", - "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==", + "version": "7.21.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.21.4.tgz", + "integrity": "sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==", "dependencies": { "@babel/highlight": "^7.18.6" }, @@ -58,17 +59,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/highlight/node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -82,47 +72,39 @@ "node": ">=4" } }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": { - "has-flag": "^3.0.0" + "eslint-visitor-keys": "^3.3.0" }, "engines": { - "node": ">=4" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.0.tgz", + "integrity": "sha512-vITaYzIcNmjn5tF5uxcZ/ft7/RXGrMUIS9HalWckEOF6ESiwXKoMzAQf2UW0aVd6rnOeExTJVd5hmWXucBKGXQ==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, "node_modules/@eslint/eslintrc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.1.tgz", - "integrity": "sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.2.tgz", + "integrity": "sha512-3W4f5tDUra+pA+FzgugqL2pRimUTDJWKr7BINqOpkZrC0uYI0NIc0/JFgBROCU07HR6GieA5m3/rsPIhDmCXTQ==", "dev": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.4.0", + "espree": "^9.5.1", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", @@ -159,6 +141,15 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "node_modules/@eslint/js": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.38.0.tgz", + "integrity": "sha512-IoD2MfUnOV58ghIHCiil01PcohxjbYR/qCxsoC+xNgUwh1EY8jOOrYmu3d3a71+tJJ23uscEV4X2HJWMsPJu4g==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.8", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", @@ -225,19 +216,19 @@ } }, "node_modules/@percy/cli": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/cli/-/cli-1.18.0.tgz", - "integrity": "sha512-yfvVh2uwTqMGxn2wF/RCvGVsyfXkeKOt05Cil4s8PRSBQ94iDY872lMJ3al0gSq0y4GLAH9CO7ZVt3uqD2tlBg==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/cli/-/cli-1.23.0.tgz", + "integrity": "sha512-3S+QUWdeJq6ZUWoRNLuX+wdJx8civJdrSmYG9WS2CP9auJNbuA+13xQnB5AkkWUvHEcC/yXzZpi5NAjoW86jgw==", "dependencies": { - "@percy/cli-app": "1.18.0", - "@percy/cli-build": "1.18.0", - "@percy/cli-command": "1.18.0", - "@percy/cli-config": "1.18.0", - "@percy/cli-exec": "1.18.0", - "@percy/cli-snapshot": "1.18.0", - "@percy/cli-upload": "1.18.0", - "@percy/client": "1.18.0", - "@percy/logger": "1.18.0" + "@percy/cli-app": "1.23.0", + "@percy/cli-build": "1.23.0", + "@percy/cli-command": "1.23.0", + "@percy/cli-config": "1.23.0", + "@percy/cli-exec": "1.23.0", + "@percy/cli-snapshot": "1.23.0", + "@percy/cli-upload": "1.23.0", + "@percy/client": "1.23.0", + "@percy/logger": "1.23.0" }, "bin": { "percy": "bin/run.cjs" @@ -247,36 +238,36 @@ } }, "node_modules/@percy/cli-app": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/cli-app/-/cli-app-1.18.0.tgz", - "integrity": "sha512-EAqD61ivuCwfl6PacXW9Wx9kTRvMPCBlQnxmrhx7jJG5tIp418p4XB3zkFOAirUa/LOdwNIVaPCHJyVAcJ1V5Q==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/cli-app/-/cli-app-1.23.0.tgz", + "integrity": "sha512-2L5chuBFp016LlkB7BihGtm0XJFCZEDNIcOFchsK7l2REBUkxVeM6hNQ89uuP2F9eKXwWKqtDEIYCzdzW0hfIQ==", "dependencies": { - "@percy/cli-command": "1.18.0", - "@percy/cli-exec": "1.18.0" + "@percy/cli-command": "1.23.0", + "@percy/cli-exec": "1.23.0" }, "engines": { "node": ">=14" } }, "node_modules/@percy/cli-build": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/cli-build/-/cli-build-1.18.0.tgz", - "integrity": "sha512-FuYWjXx4Wy0v27GpwxGX5qDq4xcLzlStbABNCp4H5RrNrLa1jkcViOKXTSurdjHYWlHHFWJUbk39D4g3ZRXOAA==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/cli-build/-/cli-build-1.23.0.tgz", + "integrity": "sha512-qIhfU/UtPl181Dw2kR8klEYLUlA5C8GE0M9781vz7D0W3LriccaLLLo1wBp4q4bo83uvUBvNJhq9/S4T38kPEQ==", "dependencies": { - "@percy/cli-command": "1.18.0" + "@percy/cli-command": "1.23.0" }, "engines": { "node": ">=14" } }, "node_modules/@percy/cli-command": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/cli-command/-/cli-command-1.18.0.tgz", - "integrity": "sha512-2dHJalp83IygD/FqXCFNND22z7f4r5uZxqr5GAyiJ0STkQYitTgwkUp+4IRdfK1zmi+A2dbyYU0xV9txEW5v8g==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/cli-command/-/cli-command-1.23.0.tgz", + "integrity": "sha512-tXj5vv2BQMBmn3ZL2YNqYYrmJLyYnBqwyJkecY2BwXQsKAIv3qBgTzr1d5+LxTOi5ArjFCHAgk2w4ohy6h6t4w==", "dependencies": { - "@percy/config": "1.18.0", - "@percy/core": "1.18.0", - "@percy/logger": "1.18.0" + "@percy/config": "1.23.0", + "@percy/core": "1.23.0", + "@percy/logger": "1.23.0" }, "bin": { "percy-cli-readme": "bin/readme.js" @@ -286,22 +277,22 @@ } }, "node_modules/@percy/cli-config": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/cli-config/-/cli-config-1.18.0.tgz", - "integrity": "sha512-N5I7av5SGO5n0YC61wbqVniNxFwAQFFubKyGyGaVlQkmKyRuYCmDMHlPdSv3zyvVD1ZJCvtDzi/VqQGDubEieg==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/cli-config/-/cli-config-1.23.0.tgz", + "integrity": "sha512-tI4c4MhU41rx9n7fYZrpn4gaOD9dA6PnefP397v7smqEWh7MJ+cxI/nyKU0/9G2wGjMhYACaLoR4BiCWOQZAkw==", "dependencies": { - "@percy/cli-command": "1.18.0" + "@percy/cli-command": "1.23.0" }, "engines": { "node": ">=14" } }, "node_modules/@percy/cli-exec": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/cli-exec/-/cli-exec-1.18.0.tgz", - "integrity": "sha512-4y+XIsYS5KypJmTtGKyKFU4GZ6902dCcEMJNDc69grSsco9N2lBG2gsvChCrCA2gDT8nUvKng9z9KF2OiMvF3w==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/cli-exec/-/cli-exec-1.23.0.tgz", + "integrity": "sha512-ecxnMWxUlVx0EswGraHgN4LvWbXeUZQZUxJ9wYmMSgDEaKfEiEZ5WTLSKzQAxyfw2SjoQ3cHRZbKh4qMlCgbAg==", "dependencies": { - "@percy/cli-command": "1.18.0", + "@percy/cli-command": "1.23.0", "cross-spawn": "^7.0.3", "which": "^2.0.2" }, @@ -310,11 +301,11 @@ } }, "node_modules/@percy/cli-snapshot": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/cli-snapshot/-/cli-snapshot-1.18.0.tgz", - "integrity": "sha512-3a7HqJU/wCT7fKraYw6S4cs8KrlUZ0MbDV9/dEbFh9nSfz0BLQjcK+kL8OzjxM+bVwZ6pf62FJl44nVLpXqrGw==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/cli-snapshot/-/cli-snapshot-1.23.0.tgz", + "integrity": "sha512-QOrUfyPCnjfIAcUBjNlO299NRPDxofcYQUCBYZE3CtemsNFtygFt0yPnZCwWmt0voSpnPl1Izc6/FA3wYUfuBQ==", "dependencies": { - "@percy/cli-command": "1.18.0", + "@percy/cli-command": "1.23.0", "yaml": "^2.0.0" }, "engines": { @@ -322,11 +313,11 @@ } }, "node_modules/@percy/cli-upload": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/cli-upload/-/cli-upload-1.18.0.tgz", - "integrity": "sha512-JSRoE0aTnBH1HGNGmDJxGNkNIGor7H8pv6lO8AUiwCwQ55EHgLqtWlhwq+2AHz7QOiGD8aOHQTwJk6GcCIIa3A==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/cli-upload/-/cli-upload-1.23.0.tgz", + "integrity": "sha512-faRHjzaUf21RK9Ra051gKUl4HmMNPZxUKSZNmdG0yP+tc5KxU9cXkmEeCKGH7LOcVs0IfyRX0vv58YEZ6GsIRw==", "dependencies": { - "@percy/cli-command": "1.18.0", + "@percy/cli-command": "1.23.0", "fast-glob": "^3.2.11", "image-size": "^1.0.0" }, @@ -335,23 +326,23 @@ } }, "node_modules/@percy/client": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/client/-/client-1.18.0.tgz", - "integrity": "sha512-onuVIpB6TPNjEhLlPsyhJYXTY2xdv3iNx4bj8Yfk751vQ33US2z4FEWDQKIEvHJGcFT7NEIHXFT3bYcFDmREdQ==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/client/-/client-1.23.0.tgz", + "integrity": "sha512-m0qNCrlfh6Pf0t2GfoeShuK7r2GeRk5rWVjIbdnDigvmtL0G+HJM1gvysLOxzKFHkZ1cLBfM1SnH1Yn6RM/6qQ==", "dependencies": { - "@percy/env": "1.18.0", - "@percy/logger": "1.18.0" + "@percy/env": "1.23.0", + "@percy/logger": "1.23.0" }, "engines": { "node": ">=14" } }, "node_modules/@percy/config": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/config/-/config-1.18.0.tgz", - "integrity": "sha512-bjAiZuhORij3vxeolVjpf7ZU1Sjqv2Y9CgsBthoIu20f5o0a10w6JGgPkDjT3NaAIZDXgbxVsrWovlslbRC57w==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/config/-/config-1.23.0.tgz", + "integrity": "sha512-giPIdNLcG1Qg0dkc/VDOkTzI4szzM4QAoJfMLEP0UYPkIU2Y0Xc8NH5GN3DEiudRJge72iGfeah6GugxmXmKXw==", "dependencies": { - "@percy/logger": "1.18.0", + "@percy/logger": "1.23.0", "ajv": "^8.6.2", "cosmiconfig": "^7.0.0", "yaml": "^2.0.0" @@ -361,15 +352,15 @@ } }, "node_modules/@percy/core": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/core/-/core-1.18.0.tgz", - "integrity": "sha512-9d8mkE6bfp0nRxhnGgC8N2KBG+MRiCxfvmZGfKENEoSY8NTTjJ5LqF+ol1JRwQ+uTABPZgy9XItWLINrS3yC1Q==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/core/-/core-1.23.0.tgz", + "integrity": "sha512-/BNHdvbD7r1p3k3HWgxYLBo2L2Ye9RDcmTuA6en2xUYaagf+0vfcAK8iyBvVm6ir2ZjAsMW0PGRa7OIfetvHHg==", "hasInstallScript": true, "dependencies": { - "@percy/client": "1.18.0", - "@percy/config": "1.18.0", - "@percy/dom": "1.18.0", - "@percy/logger": "1.18.0", + "@percy/client": "1.23.0", + "@percy/config": "1.23.0", + "@percy/dom": "1.23.0", + "@percy/logger": "1.23.0", "content-disposition": "^0.5.4", "cross-spawn": "^7.0.3", "extract-zip": "^2.0.1", @@ -385,22 +376,22 @@ } }, "node_modules/@percy/dom": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/dom/-/dom-1.18.0.tgz", - "integrity": "sha512-FoaUgmdCaymSVV/5UQsDwPlZYpSymViODiGJxEJnfESKB4L5aNWQTL3QefFOAI67Q9lUIezW7oueLPsH2XlCNg==" + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/dom/-/dom-1.23.0.tgz", + "integrity": "sha512-68q3ceCWsWpUFyF/pnELSCTdbTAibGVyNwp+iZCFd/914sUhERYrrX8AqCgkCDerOzCwAQZQDe2Nv3jaB+d0ng==" }, "node_modules/@percy/env": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/env/-/env-1.18.0.tgz", - "integrity": "sha512-b+RTDPst4yKk67EQMjGeBIjfAkqZy2jUXgW3SKaNCyCOzI+16IXJ1gJSrniv29TpxgqDa1y3OUyWTPkmuDVi2A==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/env/-/env-1.23.0.tgz", + "integrity": "sha512-oKvJBC/Zhfwp2QpFBpfHeAVuGhgaPeI7S4H2/68XT30pInfVJzaCjD/8ySAELGyMWmgHc51s+k09DZCo3C3Gyg==", "engines": { "node": ">=14" } }, "node_modules/@percy/logger": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/logger/-/logger-1.18.0.tgz", - "integrity": "sha512-ZC9OqaTVPjnndcSfbQaU0NcquC0J4KZFx7hEDznukXNsLIK4WSLiEK1QS+tGxAkIKZilHmVc/vv9q3lMvlQDaQ==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/logger/-/logger-1.23.0.tgz", + "integrity": "sha512-kNtdKQ9Kou/RcWgDoSK+ofOVqOzuzyHBNsK+I92XNh8HHO6ow08Cmw+LtZbDxmj3uq7nXG9Nhgj4ZqSgdk7J6Q==", "engines": { "node": ">=14" } @@ -468,9 +459,9 @@ "dev": true }, "node_modules/@types/uuid": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.0.tgz", - "integrity": "sha512-kr90f+ERiQtKWMz5rP32ltJ/BtULDI5RVO0uavn1HQUOwjx0R1h0rnDYNL0CepF1zL5bSY6FISAfd9tOdDhU5Q==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==", "dev": true }, "node_modules/@types/yauzl": { @@ -483,19 +474,19 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.51.0.tgz", - "integrity": "sha512-wcAwhEWm1RgNd7dxD/o+nnLW8oH+6RK1OGnmbmkj/GGoDPV1WWMVP0FXYQBivKHdwM1pwii3bt//RC62EriIUQ==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.0.tgz", + "integrity": "sha512-p0QgrEyrxAWBecR56gyn3wkG15TJdI//eetInP3zYRewDh0XS+DhB3VUAd3QqvziFsfaQIoIuZMxZRB7vXYaYw==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "5.51.0", - "@typescript-eslint/type-utils": "5.51.0", - "@typescript-eslint/utils": "5.51.0", + "@eslint-community/regexpp": "^4.4.0", + "@typescript-eslint/scope-manager": "5.59.0", + "@typescript-eslint/type-utils": "5.59.0", + "@typescript-eslint/utils": "5.59.0", "debug": "^4.3.4", "grapheme-splitter": "^1.0.4", "ignore": "^5.2.0", "natural-compare-lite": "^1.4.0", - "regexpp": "^3.2.0", "semver": "^7.3.7", "tsutils": "^3.21.0" }, @@ -517,14 +508,14 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "5.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.51.0.tgz", - "integrity": "sha512-fEV0R9gGmfpDeRzJXn+fGQKcl0inIeYobmmUWijZh9zA7bxJ8clPhV9up2ZQzATxAiFAECqPQyMDB4o4B81AaA==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.0.tgz", + "integrity": "sha512-qK9TZ70eJtjojSUMrrEwA9ZDQ4N0e/AuoOIgXuNBorXYcBDk397D2r5MIe1B3cok/oCtdNC5j+lUUpVB+Dpb+w==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "5.51.0", - "@typescript-eslint/types": "5.51.0", - "@typescript-eslint/typescript-estree": "5.51.0", + "@typescript-eslint/scope-manager": "5.59.0", + "@typescript-eslint/types": "5.59.0", + "@typescript-eslint/typescript-estree": "5.59.0", "debug": "^4.3.4" }, "engines": { @@ -544,13 +535,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "5.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.51.0.tgz", - "integrity": "sha512-gNpxRdlx5qw3yaHA0SFuTjW4rxeYhpHxt491PEcKF8Z6zpq0kMhe0Tolxt0qjlojS+/wArSDlj/LtE69xUJphQ==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.0.tgz", + "integrity": "sha512-tsoldKaMh7izN6BvkK6zRMINj4Z2d6gGhO2UsI8zGZY3XhLq1DndP3Ycjhi1JwdwPRwtLMW4EFPgpuKhbCGOvQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.51.0", - "@typescript-eslint/visitor-keys": "5.51.0" + "@typescript-eslint/types": "5.59.0", + "@typescript-eslint/visitor-keys": "5.59.0" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -561,13 +552,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "5.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.51.0.tgz", - "integrity": "sha512-QHC5KKyfV8sNSyHqfNa0UbTbJ6caB8uhcx2hYcWVvJAZYJRBo5HyyZfzMdRx8nvS+GyMg56fugMzzWnojREuQQ==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.0.tgz", + "integrity": "sha512-d/B6VSWnZwu70kcKQSCqjcXpVH+7ABKH8P1KNn4K7j5PXXuycZTPXF44Nui0TEm6rbWGi8kc78xRgOC4n7xFgA==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "5.51.0", - "@typescript-eslint/utils": "5.51.0", + "@typescript-eslint/typescript-estree": "5.59.0", + "@typescript-eslint/utils": "5.59.0", "debug": "^4.3.4", "tsutils": "^3.21.0" }, @@ -588,9 +579,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "5.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.51.0.tgz", - "integrity": "sha512-SqOn0ANn/v6hFn0kjvLwiDi4AzR++CBZz0NV5AnusT2/3y32jdc0G4woXPWHCumWtUXZKPAS27/9vziSsC9jnw==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.0.tgz", + "integrity": "sha512-yR2h1NotF23xFFYKHZs17QJnB51J/s+ud4PYU4MqdZbzeNxpgUr05+dNeCN/bb6raslHvGdd6BFCkVhpPk/ZeA==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -601,13 +592,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.51.0.tgz", - "integrity": "sha512-TSkNupHvNRkoH9FMA3w7TazVFcBPveAAmb7Sz+kArY6sLT86PA5Vx80cKlYmd8m3Ha2SwofM1KwraF24lM9FvA==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.0.tgz", + "integrity": "sha512-sUNnktjmI8DyGzPdZ8dRwW741zopGxltGs/SAPgGL/AAgDpiLsCFLcMNSpbfXfmnNeHmK9h3wGmCkGRGAoUZAg==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.51.0", - "@typescript-eslint/visitor-keys": "5.51.0", + "@typescript-eslint/types": "5.59.0", + "@typescript-eslint/visitor-keys": "5.59.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -628,18 +619,18 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "5.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.51.0.tgz", - "integrity": "sha512-76qs+5KWcaatmwtwsDJvBk4H76RJQBFe+Gext0EfJdC3Vd2kpY2Pf//OHHzHp84Ciw0/rYoGTDnIAr3uWhhJYw==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.0.tgz", + "integrity": "sha512-GGLFd+86drlHSvPgN/el6dRQNYYGOvRSDVydsUaQluwIW3HvbXuxyuD5JETvBt/9qGYe+lOrDk6gRrWOHb/FvA==", "dev": true, "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", "@types/json-schema": "^7.0.9", "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.51.0", - "@typescript-eslint/types": "5.51.0", - "@typescript-eslint/typescript-estree": "5.51.0", + "@typescript-eslint/scope-manager": "5.59.0", + "@typescript-eslint/types": "5.59.0", + "@typescript-eslint/typescript-estree": "5.59.0", "eslint-scope": "^5.1.1", - "eslint-utils": "^3.0.0", "semver": "^7.3.7" }, "engines": { @@ -654,12 +645,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.51.0.tgz", - "integrity": "sha512-Oh2+eTdjHjOFjKA27sxESlA87YPSOJafGCR0md5oeMdh1ZcCfAGCIOL216uTBAkAIptvLIfKQhl7lHxMJet4GQ==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.0.tgz", + "integrity": "sha512-qZ3iXxQhanchCeaExlKPV3gDQFxMUmU35xfd5eCXB6+kUw1TUAbIy2n7QIrwz9s98DQLzNWyHp61fY0da4ZcbA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "5.51.0", + "@typescript-eslint/types": "5.59.0", "eslint-visitor-keys": "^3.3.0" }, "engines": { @@ -716,17 +707,14 @@ } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "dependencies": { - "color-convert": "^2.0.1" + "color-convert": "^1.9.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=4" } }, "node_modules/argparse": { @@ -814,7 +802,21 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/color-convert": { + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", @@ -825,11 +827,43 @@ "node": ">=7.0.0" } }, - "node_modules/color-name": { + "node_modules/chalk/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, + "node_modules/chalk/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -880,6 +914,24 @@ "node": ">= 6" } }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -921,9 +973,9 @@ "dev": true }, "node_modules/deepmerge": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.0.tgz", - "integrity": "sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "engines": { "node": ">=0.10.0" } @@ -993,12 +1045,15 @@ } }, "node_modules/eslint": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.34.0.tgz", - "integrity": "sha512-1Z8iFsucw+7kSqXNZVslXS8Ioa4u2KM7GPwuKtkTFAqZ/cHMcEaR+1+Br0wLlot49cNxIiZk5wp8EAbPcYZxTg==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.38.0.tgz", + "integrity": "sha512-pIdsD2jwlUGf/U38Jv97t8lq6HpaU/G9NKbYmpWpZGw3LdTNhZLbJePqxOXGB5+JEKfOPU/XLxYxFh03nr1KTg==", "dev": true, "dependencies": { - "@eslint/eslintrc": "^1.4.1", + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.4.0", + "@eslint/eslintrc": "^2.0.2", + "@eslint/js": "8.38.0", "@humanwhocodes/config-array": "^0.11.8", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", @@ -1009,10 +1064,9 @@ "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.1.1", - "eslint-utils": "^3.0.0", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.4.0", - "esquery": "^1.4.0", + "eslint-visitor-keys": "^3.4.0", + "espree": "^9.5.1", + "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", @@ -1033,7 +1087,6 @@ "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.1", - "regexpp": "^3.2.0", "strip-ansi": "^6.0.1", "strip-json-comments": "^3.1.0", "text-table": "^0.2.0" @@ -1070,40 +1123,16 @@ "node": ">=4.0" } }, - "node_modules/eslint-utils": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", - "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", - "dev": true, - "dependencies": { - "eslint-visitor-keys": "^2.0.0" - }, - "engines": { - "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=5" - } - }, - "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "dev": true, - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-visitor-keys": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz", + "integrity": "sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/ajv": { @@ -1154,14 +1183,14 @@ "dev": true }, "node_modules/espree": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.4.1.tgz", - "integrity": "sha512-XwctdmTO6SIvCzd9810yyNzIrOrqNYV9Koizx4C/mRhf9uq0o4yHoCEU/670pOxOL/MSraektvSAji79kX90Vg==", + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz", + "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==", "dev": true, "dependencies": { "acorn": "^8.8.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.0" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -1171,9 +1200,9 @@ } }, "node_modules/esquery": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", - "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", "dev": true, "dependencies": { "estraverse": "^5.1.0" @@ -1499,11 +1528,11 @@ "dev": true }, "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", "engines": { - "node": ">=8" + "node": ">=4" } }, "node_modules/ignore": { @@ -1963,9 +1992,9 @@ } }, "node_modules/prettier": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.4.tgz", - "integrity": "sha512-vIS4Rlc2FNh0BySk3Wkd6xmwxB0FpOndW5fisM5H8hsZSxU2VWVB5CWIkIjWvrHjIhxk2g3bfMKM87zNTrZddw==", + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.7.tgz", + "integrity": "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==", "dev": true, "bin": { "prettier": "bin-prettier.js" @@ -2021,18 +2050,6 @@ } ] }, - "node_modules/regexpp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", - "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2199,14 +2216,14 @@ } }, "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "dependencies": { - "has-flag": "^4.0.0" + "has-flag": "^3.0.0" }, "engines": { - "node": ">=8" + "node": ">=4" } }, "node_modules/text-table": { @@ -2272,16 +2289,16 @@ } }, "node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=12.20" } }, "node_modules/unfetch": { @@ -2342,9 +2359,9 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "node_modules/ws": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.12.0.tgz", - "integrity": "sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", + "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", "engines": { "node": ">=10.0.0" }, @@ -2393,9 +2410,9 @@ }, "dependencies": { "@babel/code-frame": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", - "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==", + "version": "7.21.4", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.21.4.tgz", + "integrity": "sha512-LYvhNKfwWSPpocw8GI7gpK2nq3HSDuEPC/uSYaALSJu9xjsalaaYFOq0Pwt5KmVqwEbZlDu81aLXwBOmD/Fv9g==", "requires": { "@babel/highlight": "^7.18.6" } @@ -2415,14 +2432,6 @@ "js-tokens": "^4.0.0" }, "dependencies": { - "ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "requires": { - "color-convert": "^1.9.0" - } - }, "chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -2432,44 +2441,33 @@ "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } - }, - "color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "requires": { - "color-name": "1.1.3" - } - }, - "color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" - }, - "supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "requires": { - "has-flag": "^3.0.0" - } } } }, + "@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "requires": { + "eslint-visitor-keys": "^3.3.0" + } + }, + "@eslint-community/regexpp": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.0.tgz", + "integrity": "sha512-vITaYzIcNmjn5tF5uxcZ/ft7/RXGrMUIS9HalWckEOF6ESiwXKoMzAQf2UW0aVd6rnOeExTJVd5hmWXucBKGXQ==", + "dev": true + }, "@eslint/eslintrc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.4.1.tgz", - "integrity": "sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.2.tgz", + "integrity": "sha512-3W4f5tDUra+pA+FzgugqL2pRimUTDJWKr7BINqOpkZrC0uYI0NIc0/JFgBROCU07HR6GieA5m3/rsPIhDmCXTQ==", "dev": true, "requires": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.4.0", + "espree": "^9.5.1", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", @@ -2498,6 +2496,12 @@ } } }, + "@eslint/js": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.38.0.tgz", + "integrity": "sha512-IoD2MfUnOV58ghIHCiil01PcohxjbYR/qCxsoC+xNgUwh1EY8jOOrYmu3d3a71+tJJ23uscEV4X2HJWMsPJu4g==", + "dev": true + }, "@humanwhocodes/config-array": { "version": "0.11.8", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", @@ -2545,114 +2549,114 @@ } }, "@percy/cli": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/cli/-/cli-1.18.0.tgz", - "integrity": "sha512-yfvVh2uwTqMGxn2wF/RCvGVsyfXkeKOt05Cil4s8PRSBQ94iDY872lMJ3al0gSq0y4GLAH9CO7ZVt3uqD2tlBg==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/cli/-/cli-1.23.0.tgz", + "integrity": "sha512-3S+QUWdeJq6ZUWoRNLuX+wdJx8civJdrSmYG9WS2CP9auJNbuA+13xQnB5AkkWUvHEcC/yXzZpi5NAjoW86jgw==", "requires": { - "@percy/cli-app": "1.18.0", - "@percy/cli-build": "1.18.0", - "@percy/cli-command": "1.18.0", - "@percy/cli-config": "1.18.0", - "@percy/cli-exec": "1.18.0", - "@percy/cli-snapshot": "1.18.0", - "@percy/cli-upload": "1.18.0", - "@percy/client": "1.18.0", - "@percy/logger": "1.18.0" + "@percy/cli-app": "1.23.0", + "@percy/cli-build": "1.23.0", + "@percy/cli-command": "1.23.0", + "@percy/cli-config": "1.23.0", + "@percy/cli-exec": "1.23.0", + "@percy/cli-snapshot": "1.23.0", + "@percy/cli-upload": "1.23.0", + "@percy/client": "1.23.0", + "@percy/logger": "1.23.0" } }, "@percy/cli-app": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/cli-app/-/cli-app-1.18.0.tgz", - "integrity": "sha512-EAqD61ivuCwfl6PacXW9Wx9kTRvMPCBlQnxmrhx7jJG5tIp418p4XB3zkFOAirUa/LOdwNIVaPCHJyVAcJ1V5Q==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/cli-app/-/cli-app-1.23.0.tgz", + "integrity": "sha512-2L5chuBFp016LlkB7BihGtm0XJFCZEDNIcOFchsK7l2REBUkxVeM6hNQ89uuP2F9eKXwWKqtDEIYCzdzW0hfIQ==", "requires": { - "@percy/cli-command": "1.18.0", - "@percy/cli-exec": "1.18.0" + "@percy/cli-command": "1.23.0", + "@percy/cli-exec": "1.23.0" } }, "@percy/cli-build": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/cli-build/-/cli-build-1.18.0.tgz", - "integrity": "sha512-FuYWjXx4Wy0v27GpwxGX5qDq4xcLzlStbABNCp4H5RrNrLa1jkcViOKXTSurdjHYWlHHFWJUbk39D4g3ZRXOAA==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/cli-build/-/cli-build-1.23.0.tgz", + "integrity": "sha512-qIhfU/UtPl181Dw2kR8klEYLUlA5C8GE0M9781vz7D0W3LriccaLLLo1wBp4q4bo83uvUBvNJhq9/S4T38kPEQ==", "requires": { - "@percy/cli-command": "1.18.0" + "@percy/cli-command": "1.23.0" } }, "@percy/cli-command": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/cli-command/-/cli-command-1.18.0.tgz", - "integrity": "sha512-2dHJalp83IygD/FqXCFNND22z7f4r5uZxqr5GAyiJ0STkQYitTgwkUp+4IRdfK1zmi+A2dbyYU0xV9txEW5v8g==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/cli-command/-/cli-command-1.23.0.tgz", + "integrity": "sha512-tXj5vv2BQMBmn3ZL2YNqYYrmJLyYnBqwyJkecY2BwXQsKAIv3qBgTzr1d5+LxTOi5ArjFCHAgk2w4ohy6h6t4w==", "requires": { - "@percy/config": "1.18.0", - "@percy/core": "1.18.0", - "@percy/logger": "1.18.0" + "@percy/config": "1.23.0", + "@percy/core": "1.23.0", + "@percy/logger": "1.23.0" } }, "@percy/cli-config": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/cli-config/-/cli-config-1.18.0.tgz", - "integrity": "sha512-N5I7av5SGO5n0YC61wbqVniNxFwAQFFubKyGyGaVlQkmKyRuYCmDMHlPdSv3zyvVD1ZJCvtDzi/VqQGDubEieg==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/cli-config/-/cli-config-1.23.0.tgz", + "integrity": "sha512-tI4c4MhU41rx9n7fYZrpn4gaOD9dA6PnefP397v7smqEWh7MJ+cxI/nyKU0/9G2wGjMhYACaLoR4BiCWOQZAkw==", "requires": { - "@percy/cli-command": "1.18.0" + "@percy/cli-command": "1.23.0" } }, "@percy/cli-exec": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/cli-exec/-/cli-exec-1.18.0.tgz", - "integrity": "sha512-4y+XIsYS5KypJmTtGKyKFU4GZ6902dCcEMJNDc69grSsco9N2lBG2gsvChCrCA2gDT8nUvKng9z9KF2OiMvF3w==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/cli-exec/-/cli-exec-1.23.0.tgz", + "integrity": "sha512-ecxnMWxUlVx0EswGraHgN4LvWbXeUZQZUxJ9wYmMSgDEaKfEiEZ5WTLSKzQAxyfw2SjoQ3cHRZbKh4qMlCgbAg==", "requires": { - "@percy/cli-command": "1.18.0", + "@percy/cli-command": "1.23.0", "cross-spawn": "^7.0.3", "which": "^2.0.2" } }, "@percy/cli-snapshot": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/cli-snapshot/-/cli-snapshot-1.18.0.tgz", - "integrity": "sha512-3a7HqJU/wCT7fKraYw6S4cs8KrlUZ0MbDV9/dEbFh9nSfz0BLQjcK+kL8OzjxM+bVwZ6pf62FJl44nVLpXqrGw==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/cli-snapshot/-/cli-snapshot-1.23.0.tgz", + "integrity": "sha512-QOrUfyPCnjfIAcUBjNlO299NRPDxofcYQUCBYZE3CtemsNFtygFt0yPnZCwWmt0voSpnPl1Izc6/FA3wYUfuBQ==", "requires": { - "@percy/cli-command": "1.18.0", + "@percy/cli-command": "1.23.0", "yaml": "^2.0.0" } }, "@percy/cli-upload": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/cli-upload/-/cli-upload-1.18.0.tgz", - "integrity": "sha512-JSRoE0aTnBH1HGNGmDJxGNkNIGor7H8pv6lO8AUiwCwQ55EHgLqtWlhwq+2AHz7QOiGD8aOHQTwJk6GcCIIa3A==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/cli-upload/-/cli-upload-1.23.0.tgz", + "integrity": "sha512-faRHjzaUf21RK9Ra051gKUl4HmMNPZxUKSZNmdG0yP+tc5KxU9cXkmEeCKGH7LOcVs0IfyRX0vv58YEZ6GsIRw==", "requires": { - "@percy/cli-command": "1.18.0", + "@percy/cli-command": "1.23.0", "fast-glob": "^3.2.11", "image-size": "^1.0.0" } }, "@percy/client": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/client/-/client-1.18.0.tgz", - "integrity": "sha512-onuVIpB6TPNjEhLlPsyhJYXTY2xdv3iNx4bj8Yfk751vQ33US2z4FEWDQKIEvHJGcFT7NEIHXFT3bYcFDmREdQ==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/client/-/client-1.23.0.tgz", + "integrity": "sha512-m0qNCrlfh6Pf0t2GfoeShuK7r2GeRk5rWVjIbdnDigvmtL0G+HJM1gvysLOxzKFHkZ1cLBfM1SnH1Yn6RM/6qQ==", "requires": { - "@percy/env": "1.18.0", - "@percy/logger": "1.18.0" + "@percy/env": "1.23.0", + "@percy/logger": "1.23.0" } }, "@percy/config": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/config/-/config-1.18.0.tgz", - "integrity": "sha512-bjAiZuhORij3vxeolVjpf7ZU1Sjqv2Y9CgsBthoIu20f5o0a10w6JGgPkDjT3NaAIZDXgbxVsrWovlslbRC57w==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/config/-/config-1.23.0.tgz", + "integrity": "sha512-giPIdNLcG1Qg0dkc/VDOkTzI4szzM4QAoJfMLEP0UYPkIU2Y0Xc8NH5GN3DEiudRJge72iGfeah6GugxmXmKXw==", "requires": { - "@percy/logger": "1.18.0", + "@percy/logger": "1.23.0", "ajv": "^8.6.2", "cosmiconfig": "^7.0.0", "yaml": "^2.0.0" } }, "@percy/core": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/core/-/core-1.18.0.tgz", - "integrity": "sha512-9d8mkE6bfp0nRxhnGgC8N2KBG+MRiCxfvmZGfKENEoSY8NTTjJ5LqF+ol1JRwQ+uTABPZgy9XItWLINrS3yC1Q==", + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/core/-/core-1.23.0.tgz", + "integrity": "sha512-/BNHdvbD7r1p3k3HWgxYLBo2L2Ye9RDcmTuA6en2xUYaagf+0vfcAK8iyBvVm6ir2ZjAsMW0PGRa7OIfetvHHg==", "requires": { - "@percy/client": "1.18.0", - "@percy/config": "1.18.0", - "@percy/dom": "1.18.0", - "@percy/logger": "1.18.0", + "@percy/client": "1.23.0", + "@percy/config": "1.23.0", + "@percy/dom": "1.23.0", + "@percy/logger": "1.23.0", "content-disposition": "^0.5.4", "cross-spawn": "^7.0.3", "extract-zip": "^2.0.1", @@ -2665,19 +2669,19 @@ } }, "@percy/dom": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/dom/-/dom-1.18.0.tgz", - "integrity": "sha512-FoaUgmdCaymSVV/5UQsDwPlZYpSymViODiGJxEJnfESKB4L5aNWQTL3QefFOAI67Q9lUIezW7oueLPsH2XlCNg==" + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/dom/-/dom-1.23.0.tgz", + "integrity": "sha512-68q3ceCWsWpUFyF/pnELSCTdbTAibGVyNwp+iZCFd/914sUhERYrrX8AqCgkCDerOzCwAQZQDe2Nv3jaB+d0ng==" }, "@percy/env": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/env/-/env-1.18.0.tgz", - "integrity": "sha512-b+RTDPst4yKk67EQMjGeBIjfAkqZy2jUXgW3SKaNCyCOzI+16IXJ1gJSrniv29TpxgqDa1y3OUyWTPkmuDVi2A==" + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/env/-/env-1.23.0.tgz", + "integrity": "sha512-oKvJBC/Zhfwp2QpFBpfHeAVuGhgaPeI7S4H2/68XT30pInfVJzaCjD/8ySAELGyMWmgHc51s+k09DZCo3C3Gyg==" }, "@percy/logger": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/@percy/logger/-/logger-1.18.0.tgz", - "integrity": "sha512-ZC9OqaTVPjnndcSfbQaU0NcquC0J4KZFx7hEDznukXNsLIK4WSLiEK1QS+tGxAkIKZilHmVc/vv9q3lMvlQDaQ==" + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@percy/logger/-/logger-1.23.0.tgz", + "integrity": "sha512-kNtdKQ9Kou/RcWgDoSK+ofOVqOzuzyHBNsK+I92XNh8HHO6ow08Cmw+LtZbDxmj3uq7nXG9Nhgj4ZqSgdk7J6Q==" }, "@percy/playwright": { "version": "1.0.4", @@ -2725,9 +2729,9 @@ "dev": true }, "@types/uuid": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.0.tgz", - "integrity": "sha512-kr90f+ERiQtKWMz5rP32ltJ/BtULDI5RVO0uavn1HQUOwjx0R1h0rnDYNL0CepF1zL5bSY6FISAfd9tOdDhU5Q==", + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==", "dev": true }, "@types/yauzl": { @@ -2740,71 +2744,71 @@ } }, "@typescript-eslint/eslint-plugin": { - "version": "5.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.51.0.tgz", - "integrity": "sha512-wcAwhEWm1RgNd7dxD/o+nnLW8oH+6RK1OGnmbmkj/GGoDPV1WWMVP0FXYQBivKHdwM1pwii3bt//RC62EriIUQ==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.0.tgz", + "integrity": "sha512-p0QgrEyrxAWBecR56gyn3wkG15TJdI//eetInP3zYRewDh0XS+DhB3VUAd3QqvziFsfaQIoIuZMxZRB7vXYaYw==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "5.51.0", - "@typescript-eslint/type-utils": "5.51.0", - "@typescript-eslint/utils": "5.51.0", + "@eslint-community/regexpp": "^4.4.0", + "@typescript-eslint/scope-manager": "5.59.0", + "@typescript-eslint/type-utils": "5.59.0", + "@typescript-eslint/utils": "5.59.0", "debug": "^4.3.4", "grapheme-splitter": "^1.0.4", "ignore": "^5.2.0", "natural-compare-lite": "^1.4.0", - "regexpp": "^3.2.0", "semver": "^7.3.7", "tsutils": "^3.21.0" } }, "@typescript-eslint/parser": { - "version": "5.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.51.0.tgz", - "integrity": "sha512-fEV0R9gGmfpDeRzJXn+fGQKcl0inIeYobmmUWijZh9zA7bxJ8clPhV9up2ZQzATxAiFAECqPQyMDB4o4B81AaA==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.0.tgz", + "integrity": "sha512-qK9TZ70eJtjojSUMrrEwA9ZDQ4N0e/AuoOIgXuNBorXYcBDk397D2r5MIe1B3cok/oCtdNC5j+lUUpVB+Dpb+w==", "dev": true, "requires": { - "@typescript-eslint/scope-manager": "5.51.0", - "@typescript-eslint/types": "5.51.0", - "@typescript-eslint/typescript-estree": "5.51.0", + "@typescript-eslint/scope-manager": "5.59.0", + "@typescript-eslint/types": "5.59.0", + "@typescript-eslint/typescript-estree": "5.59.0", "debug": "^4.3.4" } }, "@typescript-eslint/scope-manager": { - "version": "5.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.51.0.tgz", - "integrity": "sha512-gNpxRdlx5qw3yaHA0SFuTjW4rxeYhpHxt491PEcKF8Z6zpq0kMhe0Tolxt0qjlojS+/wArSDlj/LtE69xUJphQ==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.0.tgz", + "integrity": "sha512-tsoldKaMh7izN6BvkK6zRMINj4Z2d6gGhO2UsI8zGZY3XhLq1DndP3Ycjhi1JwdwPRwtLMW4EFPgpuKhbCGOvQ==", "dev": true, "requires": { - "@typescript-eslint/types": "5.51.0", - "@typescript-eslint/visitor-keys": "5.51.0" + "@typescript-eslint/types": "5.59.0", + "@typescript-eslint/visitor-keys": "5.59.0" } }, "@typescript-eslint/type-utils": { - "version": "5.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.51.0.tgz", - "integrity": "sha512-QHC5KKyfV8sNSyHqfNa0UbTbJ6caB8uhcx2hYcWVvJAZYJRBo5HyyZfzMdRx8nvS+GyMg56fugMzzWnojREuQQ==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.0.tgz", + "integrity": "sha512-d/B6VSWnZwu70kcKQSCqjcXpVH+7ABKH8P1KNn4K7j5PXXuycZTPXF44Nui0TEm6rbWGi8kc78xRgOC4n7xFgA==", "dev": true, "requires": { - "@typescript-eslint/typescript-estree": "5.51.0", - "@typescript-eslint/utils": "5.51.0", + "@typescript-eslint/typescript-estree": "5.59.0", + "@typescript-eslint/utils": "5.59.0", "debug": "^4.3.4", "tsutils": "^3.21.0" } }, "@typescript-eslint/types": { - "version": "5.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.51.0.tgz", - "integrity": "sha512-SqOn0ANn/v6hFn0kjvLwiDi4AzR++CBZz0NV5AnusT2/3y32jdc0G4woXPWHCumWtUXZKPAS27/9vziSsC9jnw==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.0.tgz", + "integrity": "sha512-yR2h1NotF23xFFYKHZs17QJnB51J/s+ud4PYU4MqdZbzeNxpgUr05+dNeCN/bb6raslHvGdd6BFCkVhpPk/ZeA==", "dev": true }, "@typescript-eslint/typescript-estree": { - "version": "5.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.51.0.tgz", - "integrity": "sha512-TSkNupHvNRkoH9FMA3w7TazVFcBPveAAmb7Sz+kArY6sLT86PA5Vx80cKlYmd8m3Ha2SwofM1KwraF24lM9FvA==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.0.tgz", + "integrity": "sha512-sUNnktjmI8DyGzPdZ8dRwW741zopGxltGs/SAPgGL/AAgDpiLsCFLcMNSpbfXfmnNeHmK9h3wGmCkGRGAoUZAg==", "dev": true, "requires": { - "@typescript-eslint/types": "5.51.0", - "@typescript-eslint/visitor-keys": "5.51.0", + "@typescript-eslint/types": "5.59.0", + "@typescript-eslint/visitor-keys": "5.59.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -2813,28 +2817,28 @@ } }, "@typescript-eslint/utils": { - "version": "5.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.51.0.tgz", - "integrity": "sha512-76qs+5KWcaatmwtwsDJvBk4H76RJQBFe+Gext0EfJdC3Vd2kpY2Pf//OHHzHp84Ciw0/rYoGTDnIAr3uWhhJYw==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.0.tgz", + "integrity": "sha512-GGLFd+86drlHSvPgN/el6dRQNYYGOvRSDVydsUaQluwIW3HvbXuxyuD5JETvBt/9qGYe+lOrDk6gRrWOHb/FvA==", "dev": true, "requires": { + "@eslint-community/eslint-utils": "^4.2.0", "@types/json-schema": "^7.0.9", "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.51.0", - "@typescript-eslint/types": "5.51.0", - "@typescript-eslint/typescript-estree": "5.51.0", + "@typescript-eslint/scope-manager": "5.59.0", + "@typescript-eslint/types": "5.59.0", + "@typescript-eslint/typescript-estree": "5.59.0", "eslint-scope": "^5.1.1", - "eslint-utils": "^3.0.0", "semver": "^7.3.7" } }, "@typescript-eslint/visitor-keys": { - "version": "5.51.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.51.0.tgz", - "integrity": "sha512-Oh2+eTdjHjOFjKA27sxESlA87YPSOJafGCR0md5oeMdh1ZcCfAGCIOL216uTBAkAIptvLIfKQhl7lHxMJet4GQ==", + "version": "5.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.0.tgz", + "integrity": "sha512-qZ3iXxQhanchCeaExlKPV3gDQFxMUmU35xfd5eCXB6+kUw1TUAbIy2n7QIrwz9s98DQLzNWyHp61fY0da4ZcbA==", "dev": true, "requires": { - "@typescript-eslint/types": "5.51.0", + "@typescript-eslint/types": "5.59.0", "eslint-visitor-keys": "^3.3.0" } }, @@ -2869,11 +2873,11 @@ "dev": true }, "ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "requires": { - "color-convert": "^2.0.1" + "color-convert": "^1.9.0" } }, "argparse": { @@ -2937,20 +2941,56 @@ "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" + }, + "dependencies": { + "ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "requires": { + "color-convert": "^2.0.1" + } + }, + "color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "requires": { + "color-name": "~1.1.4" + } + }, + "color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "requires": { + "has-flag": "^4.0.0" + } + } } }, "color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", "requires": { - "color-name": "~1.1.4" + "color-name": "1.1.3" } }, "color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, "combined-stream": { "version": "1.0.8", @@ -2992,6 +3032,15 @@ } } }, + "cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.1" + } + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -3024,9 +3073,9 @@ "dev": true }, "deepmerge": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.0.tgz", - "integrity": "sha512-z2wJZXrmeHdvYJp/Ux55wIjqo81G5Bp4c+oELTW+7ar6SogWHajt5a9gO3s3IDaGSAXjDk0vlQKN3rms8ab3og==" + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" }, "delayed-stream": { "version": "1.0.0", @@ -3078,12 +3127,15 @@ "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" }, "eslint": { - "version": "8.34.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.34.0.tgz", - "integrity": "sha512-1Z8iFsucw+7kSqXNZVslXS8Ioa4u2KM7GPwuKtkTFAqZ/cHMcEaR+1+Br0wLlot49cNxIiZk5wp8EAbPcYZxTg==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.38.0.tgz", + "integrity": "sha512-pIdsD2jwlUGf/U38Jv97t8lq6HpaU/G9NKbYmpWpZGw3LdTNhZLbJePqxOXGB5+JEKfOPU/XLxYxFh03nr1KTg==", "dev": true, "requires": { - "@eslint/eslintrc": "^1.4.1", + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.4.0", + "@eslint/eslintrc": "^2.0.2", + "@eslint/js": "8.38.0", "@humanwhocodes/config-array": "^0.11.8", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", @@ -3094,10 +3146,9 @@ "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.1.1", - "eslint-utils": "^3.0.0", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.4.0", - "esquery": "^1.4.0", + "eslint-visitor-keys": "^3.4.0", + "espree": "^9.5.1", + "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", @@ -3118,7 +3169,6 @@ "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.1", - "regexpp": "^3.2.0", "strip-ansi": "^6.0.1", "strip-json-comments": "^3.1.0", "text-table": "^0.2.0" @@ -3178,44 +3228,27 @@ } } }, - "eslint-utils": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz", - "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==", - "dev": true, - "requires": { - "eslint-visitor-keys": "^2.0.0" - }, - "dependencies": { - "eslint-visitor-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz", - "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==", - "dev": true - } - } - }, "eslint-visitor-keys": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz", + "integrity": "sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ==", "dev": true }, "espree": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.4.1.tgz", - "integrity": "sha512-XwctdmTO6SIvCzd9810yyNzIrOrqNYV9Koizx4C/mRhf9uq0o4yHoCEU/670pOxOL/MSraektvSAji79kX90Vg==", + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz", + "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==", "dev": true, "requires": { "acorn": "^8.8.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.0" } }, "esquery": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz", - "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", "dev": true, "requires": { "estraverse": "^5.1.0" @@ -3449,9 +3482,9 @@ "dev": true }, "has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==" }, "ignore": { "version": "5.2.0", @@ -3781,9 +3814,9 @@ "dev": true }, "prettier": { - "version": "2.8.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.4.tgz", - "integrity": "sha512-vIS4Rlc2FNh0BySk3Wkd6xmwxB0FpOndW5fisM5H8hsZSxU2VWVB5CWIkIjWvrHjIhxk2g3bfMKM87zNTrZddw==", + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.7.tgz", + "integrity": "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==", "dev": true }, "pump": { @@ -3813,12 +3846,6 @@ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" }, - "regexpp": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", - "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", - "dev": true - }, "require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3916,11 +3943,11 @@ "dev": true }, "supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "requires": { - "has-flag": "^4.0.0" + "has-flag": "^3.0.0" } }, "text-table": { @@ -3968,9 +3995,9 @@ "dev": true }, "typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", "dev": true }, "unfetch": { @@ -4016,9 +4043,9 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "ws": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.12.0.tgz", - "integrity": "sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==", + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", + "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", "requires": {} }, "yaml": { diff --git a/e2e-tests/playwright/package.json b/e2e-tests/playwright/package.json index d2c21cbc69..e2e7ecf1ee 100644 --- a/e2e-tests/playwright/package.json +++ b/e2e-tests/playwright/package.json @@ -1,33 +1,35 @@ { "scripts": { - "test": "PW_SNAPSHOT_ENABLE=true playwright test", - "percy": "PERCY_TOKEN=$PERCY_TOKEN PW_PERCY_ENABLE=true percy exec -- playwright test --project=chrome --project=iphone --project=ipad", + "test": "cross-env PW_SNAPSHOT_ENABLE=true playwright test", + "percy": "cross-env PERCY_TOKEN=$PERCY_TOKEN PW_PERCY_ENABLE=true percy exec -- playwright test --project=chrome --project=iphone --project=ipad", "tsc": "tsc -b", "lint": "eslint . --ext .js,.ts", "prettier": "prettier --write .", "check": "npm run tsc && npm run lint && npm run prettier", - "codegen": "playwright codegen $PW_BASE_URL", - "test-slomo": "PW_SNAPSHOT_ENABLE=true PW_HEADLESS=false PW_SLOWMO=1000 playwright test", + "codegen": "cross-env playwright codegen $PW_BASE_URL", + "playwright-ui": "playwright test --ui", + "test-slomo": "cross-env PW_SNAPSHOT_ENABLE=true PW_SLOWMO=1000 playwright test", "show-report": "npx playwright show-report" }, "dependencies": { - "@percy/cli": "1.18.0", + "@percy/cli": "1.23.0", "@percy/playwright": "1.0.4", "@playwright/test": "1.32.3", "async-wait-until": "2.0.12", "chalk": "4.1.2", - "deepmerge": "4.3.0", + "deepmerge": "4.3.1", "dotenv": "16.0.3", "form-data": "4.0.0", "isomorphic-unfetch": "4.0.2", "uuid": "9.0.0" }, "devDependencies": { - "@types/uuid": "9.0.0", - "@typescript-eslint/eslint-plugin": "5.51.0", - "@typescript-eslint/parser": "5.51.0", - "eslint": "8.34.0", - "prettier": "2.8.4", - "typescript": "4.9.5" + "@types/uuid": "9.0.1", + "@typescript-eslint/eslint-plugin": "5.59.0", + "@typescript-eslint/parser": "5.59.0", + "cross-env": "7.0.3", + "eslint": "8.38.0", + "prettier": "2.8.7", + "typescript": "5.0.4" } } diff --git a/e2e-tests/playwright/sample.env b/e2e-tests/playwright/sample.env index d474d199e6..73ffe19981 100644 --- a/e2e-tests/playwright/sample.env +++ b/e2e-tests/playwright/sample.env @@ -35,7 +35,7 @@ # - Default to "false" if not set. # 12. PW_HEADLESS -# - Default to "true" if not set. Set to false to run test in head mode. +# - Default to "false" or headless mode if not set. Set to true to run test in headed mode. # 13. PW_SLOWMO # - Default to "0" if not set which means normal test speed run. Slows down Playwright operations by the specified amount of milliseconds. Useful so that you can see what is going on. diff --git a/e2e-tests/playwright/support/browser_context.ts b/e2e-tests/playwright/support/browser_context.ts index 59badf9a9c..3aead7e46e 100644 --- a/e2e-tests/playwright/support/browser_context.ts +++ b/e2e-tests/playwright/support/browser_context.ts @@ -3,16 +3,18 @@ import {writeFile} from 'node:fs/promises'; -import {request, Browser} from '@playwright/test'; +import {request, Browser, BrowserContext} from '@playwright/test'; import {UserProfile} from '@mattermost/types/users'; import testConfig from '@e2e-test.config'; export class TestBrowser { readonly browser: Browser; + context: BrowserContext | null; constructor(browser: Browser) { this.browser = browser; + this.context = null; } async login(user: UserProfile | null) { @@ -27,8 +29,16 @@ export class TestBrowser { const context = await this.browser.newContext(options); const page = await context.newPage(); + this.context = context; + return {context, page}; } + + async close() { + if (this.context) { + await this.context.close(); + } + } } export async function loginByAPI(loginId: string, password: string, token = '', ldapOnly = false) { diff --git a/e2e-tests/playwright/support/server/client.ts b/e2e-tests/playwright/support/server/client.ts index 703275e0d7..6a41218e23 100644 --- a/e2e-tests/playwright/support/server/client.ts +++ b/e2e-tests/playwright/support/server/client.ts @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -// This is based on "packages/client/src/client4.ts". Modified for node client. +// This is based on "webapp/platform/client/src/client4.ts". Modified for node client. // Update should be made in comparison with the base Client4. import fs from 'node:fs'; @@ -134,7 +134,7 @@ export default class Client extends Client4 { // ***************************************************************************** // Boards client - // based on https://github.com/mattermost/focalboard/blob/main/webapp/src/octoClient.ts + // based on "webapp/boards/src/octoClient.ts" // ***************************************************************************** async patchUserConfig(userID: string, patch: UserConfigPatch): Promise { diff --git a/e2e-tests/playwright/support/server/default_config.ts b/e2e-tests/playwright/support/server/default_config.ts index a5b92aeb89..c62f808707 100644 --- a/e2e-tests/playwright/support/server/default_config.ts +++ b/e2e-tests/playwright/support/server/default_config.ts @@ -318,7 +318,6 @@ const defaultServerConfig: AdminConfig = { LoginButtonColor: '#0000', LoginButtonBorderColor: '#2389D7', LoginButtonTextColor: '#2389D7', - EnableInactivityEmail: true, }, RateLimitSettings: { Enable: false, @@ -532,6 +531,7 @@ const defaultServerConfig: AdminConfig = { EnableRemoteClusterService: false, EnableAppBar: false, PatchPluginsReactDOM: false, + DisableRefetchingOnBrowserFocus: false, }, AnalyticsSettings: { MaxUsersForStatistics: 2500, @@ -621,12 +621,6 @@ const defaultServerConfig: AdminConfig = { 'com.mattermost.nps': { Enable: true, }, - focalboard: { - Enable: true, - }, - playbooks: { - Enable: true, - }, }, EnableMarketplace: true, EnableRemoteMarketplace: true, @@ -670,13 +664,11 @@ const defaultServerConfig: AdminConfig = { BoardsFeatureFlags: '', BoardsDataRetention: false, NormalizeLdapDNs: false, - EnableInactivityCheckJob: true, - UseCaseOnboarding: true, GraphQL: false, InsightsEnabled: true, CommandPalette: false, SendWelcomePost: true, - WorkTemplate: false, + WorkTemplate: true, PostPriority: true, WysiwygEditor: false, PeopleProduct: false, @@ -685,7 +677,9 @@ const defaultServerConfig: AdminConfig = { ThreadsEverywhere: false, GlobalDrafts: true, OnboardingTourTips: true, + DeprecateCloudFree: false, AppsSidebarCategory: false, + CloudReverseTrial: false, }, ImportSettings: { Directory: './import', diff --git a/e2e-tests/playwright/support/server/init.ts b/e2e-tests/playwright/support/server/init.ts index c8527ea318..4f33bafb80 100644 --- a/e2e-tests/playwright/support/server/init.ts +++ b/e2e-tests/playwright/support/server/init.ts @@ -3,7 +3,9 @@ import path from 'node:path'; import {expect} from '@playwright/test'; +import chalk from 'chalk'; +import {ClientError} from '@mattermost/client/client4'; import {PreferenceType} from '@mattermost/types/preferences'; import testConfig from '@e2e-test.config'; @@ -77,10 +79,21 @@ export async function initSetup({ offTopicUrl: getUrl(team.name, 'off-topic'), townSquareUrl: getUrl(team.name, 'town-square'), }; - } catch (err) { + } catch (error) { // log an error for debugging // eslint-disable-next-line no-console - console.log(err); + const err = error as ClientError; + if (err.message === 'Could not parse multipart form.') { + // eslint-disable-next-line no-console + console.log(chalk.yellow(`node version: ${process.version}\nNODE_OPTIONS: ${process.env.NODE_OPTIONS}`)); + + // eslint-disable-next-line no-console + console.log( + chalk.green( + `This failed due to the experimental fetch support in Node.js starting v18.0.0.\nYou may set environment variable: "export NODE_OPTIONS='--no-experimental-fetch'", then try again.'` + ) + ); + } expect(err, 'Should not throw an error').toBeFalsy(); throw err; } diff --git a/e2e-tests/playwright/support/test_fixture.ts b/e2e-tests/playwright/support/test_fixture.ts index d35435dd7c..1614f5a3cc 100644 --- a/e2e-tests/playwright/support/test_fixture.ts +++ b/e2e-tests/playwright/support/test_fixture.ts @@ -18,6 +18,7 @@ export const test = base.extend({ pw: async ({browser}, use) => { const pw = new PlaywrightExtended(browser); await use(pw); + await pw.testBrowser.close(); }, // eslint-disable-next-line no-empty-pattern pages: async ({}, use) => { diff --git a/e2e-tests/playwright/support/ui/components/global_header.ts b/e2e-tests/playwright/support/ui/components/global_header.ts index ccd1d48ee4..8ab49d6307 100644 --- a/e2e-tests/playwright/support/ui/components/global_header.ts +++ b/e2e-tests/playwright/support/ui/components/global_header.ts @@ -16,7 +16,7 @@ export default class GlobalHeader { async switchProduct(name: string) { await this.productSwitchMenu.click(); - await this.container.getByRole('link', {name: ` ${name}`}).click(); + await this.container.getByRole('link', {name}).click(); } async toBeVisible(name: string) { diff --git a/e2e-tests/playwright/test.config.ts b/e2e-tests/playwright/test.config.ts index 5a370a541e..e0f8eb9055 100644 --- a/e2e-tests/playwright/test.config.ts +++ b/e2e-tests/playwright/test.config.ts @@ -55,7 +55,7 @@ const config: TestConfig = { // CI isCI: !!process.env.CI, // Playwright - headless: parseBool(process.env.PW_HEADLESS, false), + headless: parseBool(process.env.PW_HEADLESS, true), slowMo: parseNumber(process.env.PW_SLOWMO, 0), workers: parseNumber(process.env.PW_WORKERS, 1), // Visual tests diff --git a/e2e-tests/playwright/tests/visual/boards/board_template.spec.ts-snapshots/board-template-chrome-linux.png b/e2e-tests/playwright/tests/visual/boards/board_template.spec.ts-snapshots/board-template-chrome-linux.png index f15014aaa4..987b4d5b8d 100644 Binary files a/e2e-tests/playwright/tests/visual/boards/board_template.spec.ts-snapshots/board-template-chrome-linux.png and b/e2e-tests/playwright/tests/visual/boards/board_template.spec.ts-snapshots/board-template-chrome-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/boards/board_template.spec.ts-snapshots/board-template-firefox-linux.png b/e2e-tests/playwright/tests/visual/boards/board_template.spec.ts-snapshots/board-template-firefox-linux.png index 1d5b1fb577..424d77357e 100644 Binary files a/e2e-tests/playwright/tests/visual/boards/board_template.spec.ts-snapshots/board-template-firefox-linux.png and b/e2e-tests/playwright/tests/visual/boards/board_template.spec.ts-snapshots/board-template-firefox-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/boards/board_template.spec.ts-snapshots/board-template-ipad-linux.png b/e2e-tests/playwright/tests/visual/boards/board_template.spec.ts-snapshots/board-template-ipad-linux.png index 35c5fcff0d..2017d565c4 100644 Binary files a/e2e-tests/playwright/tests/visual/boards/board_template.spec.ts-snapshots/board-template-ipad-linux.png and b/e2e-tests/playwright/tests/visual/boards/board_template.spec.ts-snapshots/board-template-ipad-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/boards/view_untitled_board.spec.ts-snapshots/view-untitled-board-chrome-linux.png b/e2e-tests/playwright/tests/visual/boards/view_untitled_board.spec.ts-snapshots/view-untitled-board-chrome-linux.png index 44d4bd469d..6ecbc24470 100644 Binary files a/e2e-tests/playwright/tests/visual/boards/view_untitled_board.spec.ts-snapshots/view-untitled-board-chrome-linux.png and b/e2e-tests/playwright/tests/visual/boards/view_untitled_board.spec.ts-snapshots/view-untitled-board-chrome-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/boards/view_untitled_board.spec.ts-snapshots/view-untitled-board-firefox-linux.png b/e2e-tests/playwright/tests/visual/boards/view_untitled_board.spec.ts-snapshots/view-untitled-board-firefox-linux.png index e0e4610033..0b93b4be18 100644 Binary files a/e2e-tests/playwright/tests/visual/boards/view_untitled_board.spec.ts-snapshots/view-untitled-board-firefox-linux.png and b/e2e-tests/playwright/tests/visual/boards/view_untitled_board.spec.ts-snapshots/view-untitled-board-firefox-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/boards/view_untitled_board.spec.ts-snapshots/view-untitled-board-ipad-linux.png b/e2e-tests/playwright/tests/visual/boards/view_untitled_board.spec.ts-snapshots/view-untitled-board-ipad-linux.png index e3811169f4..4f4ae9d5f5 100644 Binary files a/e2e-tests/playwright/tests/visual/boards/view_untitled_board.spec.ts-snapshots/view-untitled-board-ipad-linux.png and b/e2e-tests/playwright/tests/visual/boards/view_untitled_board.spec.ts-snapshots/view-untitled-board-ipad-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-chrome-linux.png b/e2e-tests/playwright/tests/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-chrome-linux.png index 405a365a7b..2c70317f10 100644 Binary files a/e2e-tests/playwright/tests/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-chrome-linux.png and b/e2e-tests/playwright/tests/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-chrome-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-firefox-linux.png b/e2e-tests/playwright/tests/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-firefox-linux.png index ae5154ee58..09c9d93521 100644 Binary files a/e2e-tests/playwright/tests/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-firefox-linux.png and b/e2e-tests/playwright/tests/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-firefox-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-ipad-linux.png b/e2e-tests/playwright/tests/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-ipad-linux.png index 574b0d8db9..0989472047 100644 Binary files a/e2e-tests/playwright/tests/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-ipad-linux.png and b/e2e-tests/playwright/tests/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-ipad-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-iphone-linux.png b/e2e-tests/playwright/tests/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-iphone-linux.png index 81636cb5b6..1898570494 100644 Binary files a/e2e-tests/playwright/tests/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-iphone-linux.png and b/e2e-tests/playwright/tests/visual/channels/intro_channel.spec.ts-snapshots/intro-to-channel-as-regular-user-iphone-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/common/landing_page.spec.ts-snapshots/landing-login-chrome-linux.png b/e2e-tests/playwright/tests/visual/common/landing_page.spec.ts-snapshots/landing-login-chrome-linux.png index 6ef5a343f5..de92fef79e 100644 Binary files a/e2e-tests/playwright/tests/visual/common/landing_page.spec.ts-snapshots/landing-login-chrome-linux.png and b/e2e-tests/playwright/tests/visual/common/landing_page.spec.ts-snapshots/landing-login-chrome-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/common/landing_page.spec.ts-snapshots/landing-login-firefox-linux.png b/e2e-tests/playwright/tests/visual/common/landing_page.spec.ts-snapshots/landing-login-firefox-linux.png index 2b1cacedfa..132a2ac892 100644 Binary files a/e2e-tests/playwright/tests/visual/common/landing_page.spec.ts-snapshots/landing-login-firefox-linux.png and b/e2e-tests/playwright/tests/visual/common/landing_page.spec.ts-snapshots/landing-login-firefox-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/common/landing_page.spec.ts-snapshots/landing-login-ipad-linux.png b/e2e-tests/playwright/tests/visual/common/landing_page.spec.ts-snapshots/landing-login-ipad-linux.png index c48169a658..b4457abe98 100644 Binary files a/e2e-tests/playwright/tests/visual/common/landing_page.spec.ts-snapshots/landing-login-ipad-linux.png and b/e2e-tests/playwright/tests/visual/common/landing_page.spec.ts-snapshots/landing-login-ipad-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/common/landing_page.spec.ts-snapshots/landing-login-iphone-linux.png b/e2e-tests/playwright/tests/visual/common/landing_page.spec.ts-snapshots/landing-login-iphone-linux.png index 23c4291d87..ea323eb8a7 100644 Binary files a/e2e-tests/playwright/tests/visual/common/landing_page.spec.ts-snapshots/landing-login-iphone-linux.png and b/e2e-tests/playwright/tests/visual/common/landing_page.spec.ts-snapshots/landing-login-iphone-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-chrome-linux.png b/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-chrome-linux.png index 1a15106d6b..676fde4484 100644 Binary files a/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-chrome-linux.png and b/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-chrome-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-error-chrome-linux.png b/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-error-chrome-linux.png index f4866a6429..b9d2760e30 100644 Binary files a/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-error-chrome-linux.png and b/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-error-chrome-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-error-firefox-linux.png b/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-error-firefox-linux.png index 61702e85cb..4a3168df92 100644 Binary files a/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-error-firefox-linux.png and b/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-error-firefox-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-error-ipad-linux.png b/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-error-ipad-linux.png index 55725177f5..1538d5f2a1 100644 Binary files a/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-error-ipad-linux.png and b/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-error-ipad-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-error-iphone-linux.png b/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-error-iphone-linux.png index 0309a4aa23..1e1cc4aafc 100644 Binary files a/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-error-iphone-linux.png and b/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-error-iphone-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-firefox-linux.png b/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-firefox-linux.png index 432cceab4e..9e13bff69f 100644 Binary files a/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-firefox-linux.png and b/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-firefox-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-ipad-linux.png b/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-ipad-linux.png index c130b58464..b44f478019 100644 Binary files a/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-ipad-linux.png and b/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-ipad-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-iphone-linux.png b/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-iphone-linux.png index daf2286100..8b050c34a7 100644 Binary files a/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-iphone-linux.png and b/e2e-tests/playwright/tests/visual/common/login.spec.ts-snapshots/login-iphone-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-chrome-linux.png b/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-chrome-linux.png index 145f87b10a..c76d7b5ba6 100644 Binary files a/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-chrome-linux.png and b/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-chrome-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-error-chrome-linux.png b/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-error-chrome-linux.png index 9214c70187..29fd43ace6 100644 Binary files a/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-error-chrome-linux.png and b/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-error-chrome-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-error-firefox-linux.png b/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-error-firefox-linux.png index 247584f634..d57364459d 100644 Binary files a/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-error-firefox-linux.png and b/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-error-firefox-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-error-ipad-linux.png b/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-error-ipad-linux.png index 8b0049c5b3..65f5f5499e 100644 Binary files a/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-error-ipad-linux.png and b/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-error-ipad-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-error-iphone-linux.png b/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-error-iphone-linux.png index d4c6f7ab48..30627d5d8d 100644 Binary files a/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-error-iphone-linux.png and b/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-error-iphone-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-firefox-linux.png b/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-firefox-linux.png index 2994527038..9f255fded9 100644 Binary files a/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-firefox-linux.png and b/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-firefox-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-ipad-linux.png b/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-ipad-linux.png index f50f71dfb3..4303ec2f29 100644 Binary files a/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-ipad-linux.png and b/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-ipad-linux.png differ diff --git a/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-iphone-linux.png b/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-iphone-linux.png index 372c5b3662..588495a8b8 100644 Binary files a/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-iphone-linux.png and b/e2e-tests/playwright/tests/visual/common/signup_email.spec.ts-snapshots/signup-email-iphone-linux.png differ diff --git a/server/Makefile b/server/Makefile index 83b4fd5531..62e30b7a1a 100644 --- a/server/Makefile +++ b/server/Makefile @@ -138,7 +138,7 @@ TEMPLATES_DIR=templates # Plugins Packages PLUGIN_PACKAGES ?= mattermost-plugin-antivirus-v0.1.2 -PLUGIN_PACKAGES += mattermost-plugin-autolink-v1.2.2 +PLUGIN_PACKAGES += mattermost-plugin-autolink-v1.4.0 PLUGIN_PACKAGES += mattermost-plugin-aws-SNS-v1.2.0 PLUGIN_PACKAGES += mattermost-plugin-calls-v0.15.1 PLUGIN_PACKAGES += mattermost-plugin-channel-export-v1.0.0 diff --git a/server/boards/services/store/sqlstore/migrate.go b/server/boards/services/store/sqlstore/migrate.go index 1c876a5168..63b4665490 100644 --- a/server/boards/services/store/sqlstore/migrate.go +++ b/server/boards/services/store/sqlstore/migrate.go @@ -70,7 +70,10 @@ func (s *SQLStore) getMigrationConnection() (*sql.DB, error) { } *settings.DriverName = s.dbType - db := sqlstore.SetupConnection("master", connectionString, &settings) + db, err := sqlstore.SetupConnection("master", connectionString, &settings, sqlstore.DBPingAttempts) + if err != nil { + return nil, err + } return db, nil } diff --git a/server/build/Dockerfile b/server/build/Dockerfile index 54bd19368a..294debc3f9 100644 --- a/server/build/Dockerfile +++ b/server/build/Dockerfile @@ -8,7 +8,7 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] ENV PATH="/mattermost/bin:${PATH}" ARG PUID=2000 ARG PGID=2000 -ARG MM_PACKAGE="https://releases.mattermost.com/7.9.2/mattermost-7.9.2-linux-amd64.tar.gz?src=docker" +ARG MM_PACKAGE="https://releases.mattermost.com/7.10.0/mattermost-7.10.0-linux-amd64.tar.gz?src=docker" # # Install needed packages and indirect dependencies RUN apt-get update \ diff --git a/server/channels/api4/apitestlib.go b/server/channels/api4/apitestlib.go index 424445de5d..c1ef006f8d 100644 --- a/server/channels/api4/apitestlib.go +++ b/server/channels/api4/apitestlib.go @@ -1093,12 +1093,6 @@ func CheckErrorMessage(tb testing.TB, err error, message string) { require.Equalf(tb, message, appError.Message, "incorrect error message, actual: %s, expected: %s", appError.Id, message) } -func CheckStartsWith(tb testing.TB, value, prefix, message string) { - tb.Helper() - - require.True(tb, strings.HasPrefix(value, prefix), message, value) -} - // Similar to s3.New() but allows initialization of signature v2 or signature v4 client. // If signV2 input is false, function always returns signature v4. // diff --git a/server/channels/api4/channel_test.go b/server/channels/api4/channel_test.go index 3afb21d731..20ef2cd05a 100644 --- a/server/channels/api4/channel_test.go +++ b/server/channels/api4/channel_test.go @@ -859,14 +859,23 @@ func TestGetPublicChannelsForTeam(t *testing.T) { require.NoError(t, err) require.Len(t, channels, 4, "wrong path") - for i, c := range channels { + var foundPublicChannel1, foundPublicChannel2 bool + for _, c := range channels { // check all channels included are open require.Equal(t, model.ChannelTypeOpen, c.Type, "should include open channel only") // only check the created 2 public channels - require.False(t, i < 2 && !(c.DisplayName == publicChannel1.DisplayName || c.DisplayName == publicChannel2.DisplayName), "should match public channel display name") + switch c.DisplayName { + case publicChannel1.DisplayName: + foundPublicChannel1 = true + case publicChannel2.DisplayName: + foundPublicChannel2 = true + } } + require.True(t, foundPublicChannel1, "failed to find publicChannel1") + require.True(t, foundPublicChannel2, "failed to find publicChannel2") + privateChannel := th.CreatePrivateChannel() channels, _, err = client.GetPublicChannelsForTeam(team.Id, 0, 100, "") require.NoError(t, err) @@ -1135,9 +1144,14 @@ func TestGetAllChannels(t *testing.T) { require.NoError(t, err) beforeCount := len(channels) - firstChannel := channels[0].Channel + deletedChannel := channels[0].Channel - _, err = client.DeleteChannel(firstChannel.Id) + // Never try to delete the default channel + if deletedChannel.Name == "town-square" { + deletedChannel = channels[1].Channel + } + + _, err = client.DeleteChannel(deletedChannel.Id) require.NoError(t, err) channels, _, err = client.GetAllChannels(0, 10000, "") @@ -1147,7 +1161,7 @@ func TestGetAllChannels(t *testing.T) { } require.NoError(t, err) require.Len(t, channels, beforeCount-1) - require.NotContains(t, ids, firstChannel.Id) + require.NotContains(t, ids, deletedChannel.Id) channels, _, err = client.GetAllChannelsIncludeDeleted(0, 10000, "") ids = []string{} @@ -1156,7 +1170,7 @@ func TestGetAllChannels(t *testing.T) { } require.NoError(t, err) require.True(t, len(channels) > beforeCount) - require.Contains(t, ids, firstChannel.Id) + require.Contains(t, ids, deletedChannel.Id) }) _, resp, err := client.GetAllChannels(0, 20, "") diff --git a/server/channels/api4/cloud.go b/server/channels/api4/cloud.go index fc1c6ce33a..1c448a0efd 100644 --- a/server/channels/api4/cloud.go +++ b/server/channels/api4/cloud.go @@ -103,6 +103,7 @@ func getSubscription(c *Context, w http.ResponseWriter, r *http.Request) { DNS: "", LastInvoice: &model.Invoice{}, DelinquentSince: subscription.DelinquentSince, + BillingType: "", } } diff --git a/server/channels/api4/command.go b/server/channels/api4/command.go index cd2fe01d49..312c0d093f 100644 --- a/server/channels/api4/command.go +++ b/server/channels/api4/command.go @@ -323,7 +323,14 @@ func executeCommand(c *Context, w http.ResponseWriter, r *http.Request) { defer c.LogAuditRec(auditRec) audit.AddEventParameterAuditable(auditRec, "command_args", &commandArgs) - // checks that user is a member of the specified channel, and that they have permission to use slash commands in it + // 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) { + c.SetPermissionError(model.PermissionCreatePost) + return + } + + // For compatibility reasons, PermissionCreatePost is also checked. + // TODO: Remove in 8.0: https://mattermost.atlassian.net/browse/MM-51274 if !c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), commandArgs.ChannelId, model.PermissionUseSlashCommands) { c.SetPermissionError(model.PermissionUseSlashCommands) return @@ -343,6 +350,13 @@ func executeCommand(c *Context, w http.ResponseWriter, r *http.Request) { // if the slash command was used in a DM or GM, ensure that the user is a member of the specified team, so that // they can't just execute slash commands against arbitrary teams if c.AppContext.Session().GetTeamByTeamId(commandArgs.TeamId) == nil { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionCreatePost) { + c.SetPermissionError(model.PermissionCreatePost) + return + } + + // For compatibility reasons, PermissionCreatePost is also checked. + // TODO: Remove in 8.0: https://mattermost.atlassian.net/browse/MM-51274 if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionUseSlashCommands) { c.SetPermissionError(model.PermissionUseSlashCommands) return diff --git a/server/channels/api4/command_test.go b/server/channels/api4/command_test.go index 8517a344fe..b1328da3b1 100644 --- a/server/channels/api4/command_test.go +++ b/server/channels/api4/command_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/mattermost/mattermost-server/server/v8/channels/app/request" "github.com/mattermost/mattermost-server/server/v8/model" "github.com/mattermost/mattermost-server/server/v8/platform/shared/mlog" ) @@ -1065,3 +1066,80 @@ func TestExecuteCommandInTeamUserIsNotOn(t *testing.T) { require.Error(t, err) CheckForbiddenStatus(t, resp) } + +func TestExecuteCommandReadOnly(t *testing.T) { + th := Setup(t).InitBasic() + ctx := request.EmptyContext(th.TestLogger) + defer th.TearDown() + client := th.Client + + enableCommands := *th.App.Config().ServiceSettings.EnableCommands + allowedInternalConnections := *th.App.Config().ServiceSettings.AllowedUntrustedInternalConnections + defer func() { + th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.EnableCommands = &enableCommands }) + th.App.UpdateConfig(func(cfg *model.Config) { + cfg.ServiceSettings.AllowedUntrustedInternalConnections = &allowedInternalConnections + }) + }() + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.EnableCommands = true }) + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.AllowedUntrustedInternalConnections = "localhost,127.0.0.1" + }) + + expectedCommandResponse := &model.CommandResponse{ + Text: "test post command response", + ResponseType: model.CommandResponseTypeInChannel, + Type: "custom_test", + Props: map[string]any{"someprop": "somevalue"}, + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + r.ParseForm() + require.Equal(t, th.BasicTeam.Name, r.FormValue("team_domain")) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(expectedCommandResponse); err != nil { + th.TestLogger.Warn("Error while writing response", mlog.Err(err)) + } + })) + defer ts.Close() + + // create a slash command on that team + postCmd := &model.Command{ + CreatorId: th.BasicUser.Id, + TeamId: th.BasicTeam.Id, + URL: ts.URL, + Method: model.CommandMethodPost, + Trigger: "postcommand", + } + _, appErr := th.App.CreateCommand(postCmd) + require.Nil(t, appErr, "failed to create post command") + + // Confirm that the command works when the channel is not read only + _, resp, err := client.ExecuteCommandWithTeam(th.BasicChannel.Id, th.BasicChannel.TeamId, "/postcommand") + require.NoError(t, err) + CheckOKStatus(t, resp) + + // Enable Enterprise features + th.App.Srv().SetLicense(model.NewTestLicense()) + + th.App.SetPhase2PermissionsMigrationStatus(true) + + _, appErr = th.App.PatchChannelModerationsForChannel( + ctx, + th.BasicChannel, + []*model.ChannelModerationPatch{{ + Name: &model.PermissionCreatePost.Id, + Roles: &model.ChannelModeratedRolesPatch{ + Guests: model.NewBool(false), + Members: model.NewBool(false), + }, + }}) + require.Nil(t, appErr) + + // Confirm that the command fails when the channel is read only + _, resp, err = client.ExecuteCommandWithTeam(th.BasicChannel.Id, th.BasicChannel.TeamId, "/postcommand") + require.Error(t, err) + CheckForbiddenStatus(t, resp) +} diff --git a/server/channels/api4/file_test.go b/server/channels/api4/file_test.go index 5e412211ae..af89e72fb0 100644 --- a/server/channels/api4/file_test.go +++ b/server/channels/api4/file_test.go @@ -15,7 +15,6 @@ import ( "net/url" "os" "path/filepath" - "runtime" "strings" "testing" "time" @@ -790,6 +789,12 @@ func TestGetFileHeaders(t *testing.T) { t.Skip("skipping because no file driver is enabled") } + CheckStartsWith := func(tb testing.TB, value, prefix, message string) { + tb.Helper() + + require.True(tb, strings.HasPrefix(value, prefix), fmt.Sprintf("%s: %s", message, value)) + } + testHeaders := func(data []byte, filename string, expectedContentType string, getInline bool, loadFile bool) func(*testing.T) { return func(t *testing.T) { if loadFile { @@ -832,11 +837,8 @@ func TestGetFileHeaders(t *testing.T) { t.Run("txt", testHeaders(data, "test.txt", "text/plain", false, false)) t.Run("html", testHeaders(data, "test.html", "text/plain", false, false)) t.Run("js", testHeaders(data, "test.js", "text/plain", false, false)) - if os.Getenv("IS_CI") == "true" { - t.Run("go", testHeaders(data, "test.go", "application/octet-stream", false, false)) - } else if runtime.GOOS == "linux" || runtime.GOOS == "darwin" { - t.Run("go", testHeaders(data, "test.go", "text/x-go; charset=utf-8", false, false)) - } + // *.go are categorized differently by different platforms + // t.Run("go", testHeaders(data, "test.go", "text/x-go; charset=utf-8", false, false)) t.Run("zip", testHeaders(data, "test.zip", "application/zip", false, false)) // Not every platform can recognize these //t.Run("exe", testHeaders(data, "test.exe", "application/x-ms", false)) diff --git a/server/channels/api4/hosted_customer.go b/server/channels/api4/hosted_customer.go index da1a3fe545..c7beef7ebd 100644 --- a/server/channels/api4/hosted_customer.go +++ b/server/channels/api4/hosted_customer.go @@ -10,7 +10,6 @@ import ( "fmt" "io" "net/http" - "reflect" "time" "github.com/pkg/errors" @@ -32,6 +31,8 @@ func (api *API) InitHostedCustomer() { api.BaseRoutes.HostedCustomer.Handle("/customer", api.APISessionRequired(selfHostedCustomer)).Methods("POST") // POST /api/v4/hosted_customer/confirm api.BaseRoutes.HostedCustomer.Handle("/confirm", api.APISessionRequired(selfHostedConfirm)).Methods("POST") + // POST /api.v4/hosted_customer/confirm-expand + api.BaseRoutes.HostedCustomer.Handle("/confirm-expand", api.APISessionRequired(selfHostedConfirmExpand)).Methods("POST") // GET /api/v4/hosted_customer/invoices api.BaseRoutes.HostedCustomer.Handle("/invoices", api.APISessionRequired(selfHostedInvoices)).Methods("GET") // GET /api/v4/hosted_customer/invoices/{invoice_id:in_[A-Za-z0-9]+}/pdf @@ -172,6 +173,7 @@ func selfHostedConfirm(c *Context, w http.ResponseWriter, r *http.Request) { c.Err = userErr return } + confirmResponse, err := c.App.Cloud().ConfirmSelfHostedSignup(confirm, user.Email) if err != nil { if confirmResponse != nil { @@ -185,9 +187,8 @@ func selfHostedConfirm(c *Context, w http.ResponseWriter, r *http.Request) { c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) return } - license, err := c.App.Srv().Platform().SaveLicense([]byte(confirmResponse.License)) - // dealing with an AppError - if !(reflect.ValueOf(err).Kind() == reflect.Ptr && reflect.ValueOf(err).IsNil()) { + license, appErr := c.App.Srv().Platform().SaveLicense([]byte(confirmResponse.License)) + if appErr != nil { if confirmResponse != nil { c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id) } @@ -325,3 +326,80 @@ func handleSubscribeToNewsletter(c *Context, w http.ResponseWriter, r *http.Requ ReturnStatusOK(w) } + +func selfHostedConfirmExpand(c *Context, w http.ResponseWriter, r *http.Request) { + const where = "Api4.selfHostedConfirmExpand" + + ensureSelfHostedAdmin(c, where) + if c.Err != nil { + return + } + + if !checkSelfHostedPurchaseEnabled(c) { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented) + return + } + + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) + return + } + + var confirm model.SelfHostedConfirmPaymentMethodRequest + err = json.Unmarshal(bodyBytes, &confirm) + if err != nil { + c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusBadRequest).Wrap(err) + return + } + + user, userErr := c.App.GetUser(c.AppContext.Session().UserId) + if userErr != nil { + c.Err = userErr + return + } + + confirmResponse, err := c.App.Cloud().ConfirmSelfHostedExpansion(confirm, user.Email) + if err != nil { + if confirmResponse != nil { + c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id) + } + + if err.Error() == fmt.Sprintf("%d", http.StatusUnprocessableEntity) { + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusUnprocessableEntity).Wrap(err) + return + } + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + license, appErr := c.App.Srv().Platform().SaveLicense([]byte(confirmResponse.License)) + // dealing with an AppError + if appErr != nil { + if confirmResponse != nil { + c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id) + } + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + clientResponse, err := json.Marshal(model.SelfHostedSignupConfirmClientResponse{ + License: utils.GetClientLicense(license), + Progress: confirmResponse.Progress, + }) + if err != nil { + if confirmResponse != nil { + c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id) + } + c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + go func() { + err := c.App.Cloud().ConfirmSelfHostedSignupLicenseApplication() + if err != nil { + c.Logger.Warn("Unable to confirm license application", mlog.Err(err)) + } + }() + + _, _ = w.Write(clientResponse) +} diff --git a/server/channels/api4/notify_admin_test.go b/server/channels/api4/notify_admin_test.go index 557ee3297c..d633421f42 100644 --- a/server/channels/api4/notify_admin_test.go +++ b/server/channels/api4/notify_admin_test.go @@ -22,7 +22,7 @@ func TestNotifyAdmin(t *testing.T) { }) require.Error(t, err) - require.Equal(t, err.Error(), ": Unable to save notify data.") + require.Equal(t, ": Unable to save notify data.", err.Error()) require.Equal(t, http.StatusInternalServerError, statusCode) }) @@ -38,7 +38,7 @@ func TestNotifyAdmin(t *testing.T) { }) require.Error(t, err) - require.Equal(t, err.Error(), ": Unable to save notify data.") + require.Equal(t, ": Unable to save notify data.", err.Error()) require.Equal(t, http.StatusInternalServerError, statusCode) }) @@ -53,7 +53,7 @@ func TestNotifyAdmin(t *testing.T) { }) require.Error(t, err) - require.Equal(t, err.Error(), ": Unable to save notify data.") + require.Equal(t, ": Unable to save notify data.", err.Error()) require.Equal(t, http.StatusInternalServerError, statusCode) }) @@ -68,7 +68,7 @@ func TestNotifyAdmin(t *testing.T) { }) require.Error(t, err) - require.Equal(t, err.Error(), ": Unable to save notify data.") + require.Equal(t, ": Unable to save notify data.", err.Error()) require.Equal(t, http.StatusInternalServerError, statusCode) }) @@ -90,7 +90,7 @@ func TestNotifyAdmin(t *testing.T) { }) require.Error(t, err) - require.Equal(t, err.Error(), ": Already notified admin") + require.Equal(t, ": Already notified admin", err.Error()) require.Equal(t, http.StatusForbidden, statusCode) }) @@ -118,7 +118,7 @@ func TestTriggerNotifyAdmin(t *testing.T) { statusCode, err := th.SystemAdminClient.TriggerNotifyAdmin(&model.NotifyAdminToUpgradeRequest{}) require.Error(t, err) - require.Equal(t, err.Error(), ": Internal error during cloud api request.") + require.Equal(t, ": Internal error during cloud api request.", err.Error()) require.Equal(t, http.StatusForbidden, statusCode) }) @@ -132,7 +132,7 @@ func TestTriggerNotifyAdmin(t *testing.T) { statusCode, err := th.Client.TriggerNotifyAdmin(&model.NotifyAdminToUpgradeRequest{}) require.Error(t, err) - require.Equal(t, err.Error(), ": You do not have the appropriate permissions.") + require.Equal(t, ": You do not have the appropriate permissions.", err.Error()) require.Equal(t, http.StatusForbidden, statusCode) }) diff --git a/server/channels/api4/plugin_test.go b/server/channels/api4/plugin_test.go index 5793041423..98ff5ac3f1 100644 --- a/server/channels/api4/plugin_test.go +++ b/server/channels/api4/plugin_test.go @@ -76,24 +76,6 @@ func TestPlugin(t *testing.T) { _, err = client.RemovePlugin(manifest.Id) require.NoError(t, err) - t.Run("install plugin from URL with slow response time", func(t *testing.T) { - if testing.Short() { - t.Skip("skipping test to install plugin from a slow response server") - } - - // Install from URL - slow server to simulate longer bundle download times - slowTestServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { - time.Sleep(60 * time.Second) // Wait longer than the previous default 30 seconds timeout - res.WriteHeader(http.StatusOK) - res.Write(tarData) - })) - defer func() { slowTestServer.Close() }() - - manifest, _, err = client.InstallPluginFromURL(slowTestServer.URL, true) - require.NoError(t, err) - assert.Equal(t, "testplugin", manifest.Id) - }) - th.App.Channels().RemovePlugin(manifest.Id) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.Enable = false }) @@ -121,6 +103,7 @@ func TestPlugin(t *testing.T) { // Successful upload manifest, _, err = client.UploadPlugin(bytes.NewReader(tarData)) require.NoError(t, err) + assert.Equal(t, "testplugin", manifest.Id) th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.EnableUploads = true }) @@ -1652,6 +1635,59 @@ func TestInstallMarketplacePlugin(t *testing.T) { require.Nil(t, manifest) assert.True(t, requestHandled) }, "verify EnterprisePlugins is true for E20") +} + +func TestInstallMarketplacePluginPrepackagedDisabled(t *testing.T) { + path, _ := fileutils.FindDir("tests") + + signatureFilename := "testplugin2.tar.gz.sig" + signatureFileReader, err := os.Open(filepath.Join(path, signatureFilename)) + require.NoError(t, err) + sigFile, err := io.ReadAll(signatureFileReader) + require.NoError(t, err) + pluginSignature := base64.StdEncoding.EncodeToString(sigFile) + + tarData, err := os.ReadFile(filepath.Join(path, "testplugin2.tar.gz")) + require.NoError(t, err) + pluginServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + res.WriteHeader(http.StatusOK) + res.Write(tarData) + })) + defer pluginServer.Close() + + samplePlugins := []*model.MarketplacePlugin{ + { + BaseMarketplacePlugin: &model.BaseMarketplacePlugin{ + HomepageURL: "https://example.com/mattermost/mattermost-plugin-nps", + IconData: "https://example.com/icon.svg", + DownloadURL: pluginServer.URL, + Manifest: &model.Manifest{ + Id: "testplugin2", + Name: "testplugin2", + Description: "a second plugin", + Version: "1.2.2", + MinServerVersion: "", + }, + }, + InstalledVersion: "", + }, + { + BaseMarketplacePlugin: &model.BaseMarketplacePlugin{ + HomepageURL: "https://example.com/mattermost/mattermost-plugin-nps", + IconData: "https://example.com/icon.svg", + DownloadURL: pluginServer.URL, + Manifest: &model.Manifest{ + Id: "testplugin2", + Name: "testplugin2", + Description: "a second plugin", + Version: "1.2.3", + MinServerVersion: "", + }, + Signature: pluginSignature, + }, + InstalledVersion: "", + }, + } t.Run("install prepackaged and remote plugins through marketplace", func(t *testing.T) { prepackagedPluginsDir := "prepackaged_plugins" @@ -1669,13 +1705,13 @@ func TestInstallMarketplacePlugin(t *testing.T) { err = testlib.CopyFile(filepath.Join(path, "testplugin.tar.gz.asc"), filepath.Join(prepackagedPluginsDir, "testplugin.tar.gz.sig")) require.NoError(t, err) - th2 := SetupConfig(t, func(cfg *model.Config) { + th := SetupConfig(t, func(cfg *model.Config) { // Disable auto-installing prepackaged plugins *cfg.PluginSettings.AutomaticPrepackagedPlugins = false }).InitBasic() - defer th2.TearDown() + defer th.TearDown() - th2.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { pluginSignatureFile, err := os.Open(filepath.Join(path, "testplugin.tar.gz.asc")) require.NoError(t, err) pluginSignatureData, err := io.ReadAll(pluginSignatureFile) @@ -1683,7 +1719,7 @@ func TestInstallMarketplacePlugin(t *testing.T) { key, err := os.Open(filepath.Join(path, "development-private-key.asc")) require.NoError(t, err) - appErr := th2.App.AddPublicKey("pub_key", key) + appErr := th.App.AddPublicKey("pub_key", key) require.Nil(t, appErr) testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { @@ -1698,14 +1734,14 @@ func TestInstallMarketplacePlugin(t *testing.T) { })) defer testServer.Close() - th2.App.UpdateConfig(func(cfg *model.Config) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.EnableMarketplace = true *cfg.PluginSettings.EnableRemoteMarketplace = false *cfg.PluginSettings.MarketplaceURL = testServer.URL *cfg.PluginSettings.AllowInsecureDownloadURL = false }) - env := th2.App.GetPluginsEnvironment() + env := th.App.GetPluginsEnvironment() pluginsResp, _, err := client.GetPlugins() require.NoError(t, err) @@ -1751,7 +1787,7 @@ func TestInstallMarketplacePlugin(t *testing.T) { require.Nil(t, manifest) // Enable remote marketplace - th2.App.UpdateConfig(func(cfg *model.Config) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.PluginSettings.EnableMarketplace = true *cfg.PluginSettings.EnableRemoteMarketplace = true *cfg.PluginSettings.MarketplaceURL = testServer.URL @@ -1784,12 +1820,12 @@ func TestInstallMarketplacePlugin(t *testing.T) { _, err = client.RemovePlugin(manifest2.Id) require.NoError(t, err) - appErr = th2.App.DeletePublicKey("pub_key") + appErr = th.App.DeletePublicKey("pub_key") require.Nil(t, appErr) }) }) - th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + t.Run("missing prepackaged and remote plugin signatures", func(t *testing.T) { prepackagedPluginsDir := "prepackaged_plugins" os.RemoveAll(prepackagedPluginsDir) @@ -1809,70 +1845,72 @@ func TestInstallMarketplacePlugin(t *testing.T) { }).InitBasic() defer th.TearDown() - key, err := os.Open(filepath.Join(path, "development-private-key.asc")) - require.NoError(t, err) - appErr := th.App.AddPublicKey("pub_key", key) - require.Nil(t, appErr) - - testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { - serverVersion := req.URL.Query().Get("server_version") - require.NotEmpty(t, serverVersion) - require.Equal(t, model.CurrentVersion, serverVersion) - - mPlugins := []*model.MarketplacePlugin{samplePlugins[0]} - require.Empty(t, mPlugins[0].Signature) - res.WriteHeader(http.StatusOK) - var out []byte - out, err = json.Marshal(mPlugins) + th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { + key, err := os.Open(filepath.Join(path, "development-private-key.asc")) require.NoError(t, err) - res.Write(out) - })) - defer testServer.Close() + appErr := th.App.AddPublicKey("pub_key", key) + require.Nil(t, appErr) - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.PluginSettings.EnableMarketplace = true - *cfg.PluginSettings.EnableRemoteMarketplace = true - *cfg.PluginSettings.MarketplaceURL = testServer.URL - *cfg.PluginSettings.AllowInsecureDownloadURL = true + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + serverVersion := req.URL.Query().Get("server_version") + require.NotEmpty(t, serverVersion) + require.Equal(t, model.CurrentVersion, serverVersion) + + mPlugins := []*model.MarketplacePlugin{samplePlugins[0]} + require.Empty(t, mPlugins[0].Signature) + res.WriteHeader(http.StatusOK) + var out []byte + out, err = json.Marshal(mPlugins) + require.NoError(t, err) + res.Write(out) + })) + defer testServer.Close() + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.PluginSettings.EnableMarketplace = true + *cfg.PluginSettings.EnableRemoteMarketplace = true + *cfg.PluginSettings.MarketplaceURL = testServer.URL + *cfg.PluginSettings.AllowInsecureDownloadURL = true + }) + + env := th.App.GetPluginsEnvironment() + plugins := env.PrepackagedPlugins() + require.Len(t, plugins, 1) + require.Equal(t, "testplugin", plugins[0].Manifest.Id) + require.Empty(t, plugins[0].Signature) + + pluginsResp, _, err := client.GetPlugins() + require.NoError(t, err) + require.Len(t, pluginsResp.Active, 0) + require.Len(t, pluginsResp.Inactive, 0) + + pRequest := &model.InstallMarketplacePluginRequest{Id: "testplugin"} + manifest, resp, err := client.InstallMarketplacePlugin(pRequest) + require.Error(t, err) + CheckInternalErrorStatus(t, resp) + require.Nil(t, manifest) + + pluginsResp, _, err = client.GetPlugins() + require.NoError(t, err) + require.Len(t, pluginsResp.Active, 0) + require.Len(t, pluginsResp.Inactive, 0) + + pRequest = &model.InstallMarketplacePluginRequest{Id: "testplugin2"} + manifest, resp, err = client.InstallMarketplacePlugin(pRequest) + require.Error(t, err) + CheckInternalErrorStatus(t, resp) + require.Nil(t, manifest) + + pluginsResp, _, err = client.GetPlugins() + require.NoError(t, err) + require.Len(t, pluginsResp.Active, 0) + require.Len(t, pluginsResp.Inactive, 0) + + // Clean up + appErr = th.App.DeletePublicKey("pub_key") + require.Nil(t, appErr) }) - - env := th.App.GetPluginsEnvironment() - plugins := env.PrepackagedPlugins() - require.Len(t, plugins, 1) - require.Equal(t, "testplugin", plugins[0].Manifest.Id) - require.Empty(t, plugins[0].Signature) - - pluginsResp, _, err := client.GetPlugins() - require.NoError(t, err) - require.Len(t, pluginsResp.Active, 0) - require.Len(t, pluginsResp.Inactive, 0) - - pRequest := &model.InstallMarketplacePluginRequest{Id: "testplugin"} - manifest, resp, err := client.InstallMarketplacePlugin(pRequest) - require.Error(t, err) - CheckInternalErrorStatus(t, resp) - require.Nil(t, manifest) - - pluginsResp, _, err = client.GetPlugins() - require.NoError(t, err) - require.Len(t, pluginsResp.Active, 0) - require.Len(t, pluginsResp.Inactive, 0) - - pRequest = &model.InstallMarketplacePluginRequest{Id: "testplugin2"} - manifest, resp, err = client.InstallMarketplacePlugin(pRequest) - require.Error(t, err) - CheckInternalErrorStatus(t, resp) - require.Nil(t, manifest) - - pluginsResp, _, err = client.GetPlugins() - require.NoError(t, err) - require.Len(t, pluginsResp.Active, 0) - require.Len(t, pluginsResp.Inactive, 0) - - // Clean up - appErr = th.App.DeletePublicKey("pub_key") - require.Nil(t, appErr) - }, "missing prepackaged and remote plugin signatures") + }) } func findClusterMessages(event model.ClusterEvent, msgs []*model.ClusterMessage) []*model.ClusterMessage { diff --git a/server/channels/api4/post_test.go b/server/channels/api4/post_test.go index e3d3863049..8ab88fefff 100644 --- a/server/channels/api4/post_test.go +++ b/server/channels/api4/post_test.go @@ -452,32 +452,68 @@ func testCreatePostWithOutgoingHook( } func TestCreatePostWithOutgoingHook_form_urlencoded(t *testing.T) { - testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsExactMatch, false) - testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsStartsWith, false) - testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "", "", []string{"file_id_1"}, app.TriggerwordsExactMatch, false) - testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "", "", []string{"file_id_1"}, app.TriggerwordsStartsWith, false) - testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsExactMatch, true) - testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsStartsWith, true) + t.Run("Case 1", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsExactMatch, false) + }) + t.Run("Case 2", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsStartsWith, false) + }) + t.Run("Case 3", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "", "", []string{"file_id_1"}, app.TriggerwordsExactMatch, false) + }) + t.Run("Case 4", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "", "", []string{"file_id_1"}, app.TriggerwordsStartsWith, false) + }) + t.Run("Case 5", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsExactMatch, true) + }) + t.Run("Case 6", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/x-www-form-urlencoded", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsStartsWith, true) + }) } func TestCreatePostWithOutgoingHook_json(t *testing.T) { - testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerword lorem ipsum", "triggerword", []string{"file_id_1, file_id_2"}, app.TriggerwordsExactMatch, false) - testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerwordaaazzz lorem ipsum", "triggerword", []string{"file_id_1, file_id_2"}, app.TriggerwordsStartsWith, false) - testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerword lorem ipsum", "", []string{"file_id_1"}, app.TriggerwordsExactMatch, false) - testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerwordaaazzz lorem ipsum", "", []string{"file_id_1"}, app.TriggerwordsStartsWith, false) - testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerword lorem ipsum", "triggerword", []string{"file_id_1, file_id_2"}, app.TriggerwordsExactMatch, true) - testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerwordaaazzz lorem ipsum", "", []string{"file_id_1"}, app.TriggerwordsStartsWith, true) + t.Run("Case 1", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerword lorem ipsum", "triggerword", []string{"file_id_1, file_id_2"}, app.TriggerwordsExactMatch, false) + }) + t.Run("Case 2", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerwordaaazzz lorem ipsum", "triggerword", []string{"file_id_1, file_id_2"}, app.TriggerwordsStartsWith, false) + }) + t.Run("Case 3", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerword lorem ipsum", "", []string{"file_id_1"}, app.TriggerwordsExactMatch, false) + }) + t.Run("Case 4", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerwordaaazzz lorem ipsum", "", []string{"file_id_1"}, app.TriggerwordsStartsWith, false) + }) + t.Run("Case 5", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerword lorem ipsum", "triggerword", []string{"file_id_1, file_id_2"}, app.TriggerwordsExactMatch, true) + }) + t.Run("Case 6", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "application/json", "application/json", "triggerwordaaazzz lorem ipsum", "", []string{"file_id_1"}, app.TriggerwordsStartsWith, true) + }) } // hooks created before we added the ContentType field should be considered as // application/x-www-form-urlencoded func TestCreatePostWithOutgoingHook_no_content_type(t *testing.T) { - testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsExactMatch, false) - testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsStartsWith, false) - testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "", []string{"file_id_1, file_id_2"}, app.TriggerwordsExactMatch, false) - testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "", []string{"file_id_1, file_id_2"}, app.TriggerwordsStartsWith, false) - testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsExactMatch, true) - testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "", []string{"file_id_1, file_id_2"}, app.TriggerwordsExactMatch, true) + t.Run("Case 1", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsExactMatch, false) + }) + t.Run("Case 2", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsStartsWith, false) + }) + t.Run("Case 3", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "", []string{"file_id_1, file_id_2"}, app.TriggerwordsExactMatch, false) + }) + t.Run("Case 4", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerwordaaazzz lorem ipsum", "", []string{"file_id_1, file_id_2"}, app.TriggerwordsStartsWith, false) + }) + t.Run("Case 5", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "triggerword", []string{"file_id_1"}, app.TriggerwordsExactMatch, true) + }) + t.Run("Case 6", func(t *testing.T) { + testCreatePostWithOutgoingHook(t, "", "application/x-www-form-urlencoded", "triggerword lorem ipsum", "", []string{"file_id_1, file_id_2"}, app.TriggerwordsExactMatch, true) + }) } func TestCreatePostPublic(t *testing.T) { @@ -3199,6 +3235,7 @@ func TestGetEditHistoryForPost(t *testing.T) { func TestCreatePostNotificationsWithCRT(t *testing.T) { th := Setup(t).InitBasic() + defer th.TearDown() rpost := th.CreatePost() th.App.UpdateConfig(func(cfg *model.Config) { diff --git a/server/channels/api4/system_test.go b/server/channels/api4/system_test.go index 5921e32802..25574e4400 100644 --- a/server/channels/api4/system_test.go +++ b/server/channels/api4/system_test.go @@ -892,6 +892,7 @@ func TestCompleteOnboarding(t *testing.T) { req := &model.CompleteOnboardingRequest{ InstallPlugins: []string{"testplugin2"}, + Organization: "my-org", } t.Run("as a regular user", func(t *testing.T) { diff --git a/server/channels/api4/team_test.go b/server/channels/api4/team_test.go index ee4a4ca74d..e9ae1bea28 100644 --- a/server/channels/api4/team_test.go +++ b/server/channels/api4/team_test.go @@ -1173,11 +1173,10 @@ func TestGetAllTeams(t *testing.T) { } var teams []*model.Team - var count int64 var resp *model.Response var err2 error if tc.WithCount { - teams, count, resp, err2 = client.GetAllTeamsWithTotalCount("", tc.Page, tc.PerPage) + teams, _, resp, err2 = client.GetAllTeamsWithTotalCount("", tc.Page, tc.PerPage) } else { teams, resp, err2 = client.GetAllTeams("", tc.Page, tc.PerPage) } @@ -1187,11 +1186,12 @@ func TestGetAllTeams(t *testing.T) { return } require.NoError(t, err2) - require.Equal(t, len(tc.ExpectedTeams), len(teams)) - for idx, team := range teams { - assert.Equal(t, tc.ExpectedTeams[idx], team.Id) + + actualTeamIds := make([]string, 0, len(tc.ExpectedTeams)) + for _, team := range teams { + actualTeamIds = append(actualTeamIds, team.Id) } - require.Equal(t, tc.ExpectedCount, count) + require.ElementsMatch(t, tc.ExpectedTeams, actualTeamIds) }) } diff --git a/server/channels/api4/user.go b/server/channels/api4/user.go index c259922e82..2269efe88c 100644 --- a/server/channels/api4/user.go +++ b/server/channels/api4/user.go @@ -3106,6 +3106,10 @@ 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.PermissionReadChannel) { + c.SetPermissionError(model.PermissionReadChannel) + return + } extendedStr := r.URL.Query().Get("extended") extended, _ := strconv.ParseBool(extendedStr) @@ -3136,6 +3140,10 @@ func getThreadsForUser(c *Context, w http.ResponseWriter, r *http.Request) { c.SetPermissionError(model.PermissionEditOtherUsers) return } + if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) { + c.SetPermissionError(model.PermissionViewTeam) + return + } options := model.GetUserThreadsOpts{ Since: 0, @@ -3213,6 +3221,10 @@ 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.PermissionReadChannel) { + c.SetPermissionError(model.PermissionReadChannel) + return + } 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 { @@ -3279,6 +3291,10 @@ 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.PermissionReadChannel) { + c.SetPermissionError(model.PermissionReadChannel) + return + } err := c.App.UpdateThreadFollowForUser(c.Params.UserId, c.Params.TeamId, c.Params.ThreadId, false) if err != nil { @@ -3338,6 +3354,10 @@ func updateReadStateAllThreadsByUser(c *Context, w http.ResponseWriter, r *http. c.SetPermissionError(model.PermissionEditOtherUsers) return } + if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), c.Params.TeamId, model.PermissionViewTeam) { + c.SetPermissionError(model.PermissionViewTeam) + return + } err := c.App.UpdateThreadsReadForUser(c.Params.UserId, c.Params.TeamId) if err != nil { diff --git a/server/channels/api4/user_test.go b/server/channels/api4/user_test.go index 8d9673ed9e..0f9ce87d33 100644 --- a/server/channels/api4/user_test.go +++ b/server/channels/api4/user_test.go @@ -6360,6 +6360,15 @@ func TestGetThreadsForUser(t *testing.T) { require.NoError(t, err) require.Equal(t, uss.TotalUnreadThreads, int64(2)) }) + + t.Run("should error when not a team member", func(t *testing.T) { + th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam) + defer th.LinkUserToTeam(th.BasicUser, th.BasicTeam) + + _, resp, err := th.Client.GetUserThreads(th.BasicUser.Id, th.BasicTeam.Id, model.GetUserThreadsOpts{}) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) } func TestThreadSocketEvents(t *testing.T) { @@ -6855,52 +6864,64 @@ func TestSingleThreadGet(t *testing.T) { }) client := th.Client - defer th.App.Srv().Store().Post().PermanentDeleteByUser(th.BasicUser.Id) - defer th.App.Srv().Store().Post().PermanentDeleteByUser(th.SystemAdminUser.Id) - // create a post by regular user - rpost, _ := postAndCheck(t, client, &model.Post{ChannelId: th.BasicChannel.Id, Message: "testMsg"}) - // reply with another - postAndCheck(t, th.SystemAdminClient, &model.Post{ChannelId: th.BasicChannel.Id, Message: "testReply", RootId: rpost.Id}) + t.Run("get single thread", func(t *testing.T) { + defer th.App.Srv().Store().Post().PermanentDeleteByUser(th.BasicUser.Id) + defer th.App.Srv().Store().Post().PermanentDeleteByUser(th.SystemAdminUser.Id) - // create another thread to check that we are not returning it by mistake - rpost2, _ := postAndCheck(t, client, &model.Post{ - ChannelId: th.BasicChannel2.Id, - Message: "testMsg2", - Metadata: &model.PostMetadata{ - Priority: &model.PostPriority{ - Priority: model.NewString(model.PostPriorityUrgent), + // create a post by regular user + rpost, _ := postAndCheck(t, client, &model.Post{ChannelId: th.BasicChannel.Id, Message: "testMsg"}) + // reply with another + postAndCheck(t, th.SystemAdminClient, &model.Post{ChannelId: th.BasicChannel.Id, Message: "testReply", RootId: rpost.Id}) + + // create another thread to check that we are not returning it by mistake + rpost2, _ := postAndCheck(t, client, &model.Post{ + ChannelId: th.BasicChannel2.Id, + Message: "testMsg2", + Metadata: &model.PostMetadata{ + Priority: &model.PostPriority{ + Priority: model.NewString(model.PostPriorityUrgent), + }, }, - }, - }) - postAndCheck(t, th.SystemAdminClient, &model.Post{ChannelId: th.BasicChannel2.Id, Message: "testReply", RootId: rpost2.Id}) + }) + postAndCheck(t, th.SystemAdminClient, &model.Post{ChannelId: th.BasicChannel2.Id, Message: "testReply", RootId: rpost2.Id}) - // regular user should have two threads with 3 replies total - threads, _ := checkThreadListReplies(t, th, th.Client, th.BasicUser.Id, 2, 2, nil) + // regular user should have two threads with 3 replies total + threads, _ := checkThreadListReplies(t, th, th.Client, th.BasicUser.Id, 2, 2, nil) - tr, _, err := th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, false) - require.NoError(t, err) - require.NotNil(t, tr) - require.Equal(t, threads.Threads[0].PostId, tr.PostId) - require.Empty(t, tr.Participants[0].Username) + tr, _, err := th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, false) + require.NoError(t, err) + require.NotNil(t, tr) + require.Equal(t, threads.Threads[0].PostId, tr.PostId) + require.Empty(t, tr.Participants[0].Username) - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.PostPriority = false + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.PostPriority = false + }) + + tr, _, err = th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, true) + require.NoError(t, err) + require.NotEmpty(t, tr.Participants[0].Username) + require.Equal(t, false, tr.IsUrgent) + + th.App.UpdateConfig(func(cfg *model.Config) { + *cfg.ServiceSettings.PostPriority = true + cfg.FeatureFlags.PostPriority = true + }) + + tr, _, err = th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, true) + require.NoError(t, err) + require.Equal(t, true, tr.IsUrgent) }) - tr, _, err = th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, true) - require.NoError(t, err) - require.NotEmpty(t, tr.Participants[0].Username) - require.Equal(t, false, tr.IsUrgent) + t.Run("should error when not a team member", func(t *testing.T) { + th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam) + defer th.LinkUserToTeam(th.BasicUser, th.BasicTeam) - th.App.UpdateConfig(func(cfg *model.Config) { - *cfg.ServiceSettings.PostPriority = true - cfg.FeatureFlags.PostPriority = true + _, resp, err := th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, model.NewId(), false) + require.Error(t, err) + CheckForbiddenStatus(t, resp) }) - - tr, _, err = th.Client.GetUserThread(th.BasicUser.Id, th.BasicTeam.Id, threads.Threads[0].PostId, true) - require.NoError(t, err) - require.Equal(t, true, tr.IsUrgent) } func TestMaintainUnreadMentionsInThread(t *testing.T) { @@ -7072,6 +7093,23 @@ func TestReadThreads(t *testing.T) { checkThreadListReplies(t, th, th.Client, th.BasicUser.Id, 1, 1, nil) }) + + t.Run("should error when not a team member", func(t *testing.T) { + th.UnlinkUserFromTeam(th.BasicUser, th.BasicTeam) + defer th.LinkUserToTeam(th.BasicUser, th.BasicTeam) + + _, resp, err := th.Client.UpdateThreadReadForUser(th.BasicUser.Id, th.BasicTeam.Id, model.NewId(), model.GetMillis()) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + _, resp, err = th.Client.SetThreadUnreadByPostId(th.BasicUser.Id, th.BasicTeam.Id, model.NewId(), model.NewId()) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + resp, err = th.Client.UpdateThreadsReadForUser(th.BasicUser.Id, th.BasicTeam.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + }) } func TestMarkThreadUnreadMentionCount(t *testing.T) { diff --git a/server/channels/api4/websocket_test.go b/server/channels/api4/websocket_test.go index 87eef66dbe..b7127814bc 100644 --- a/server/channels/api4/websocket_test.go +++ b/server/channels/api4/websocket_test.go @@ -424,10 +424,14 @@ func TestWebSocketUpgrade(t *testing.T) { th := Setup(t) defer th.TearDown() + buffer := &mlog.Buffer{} + err := mlog.AddWriterTarget(th.TestLogger, buffer, true, mlog.StdAll...) + require.NoError(t, err) + url := fmt.Sprintf("http://localhost:%v", th.App.Srv().ListenAddr.Port) + model.APIURLSuffix + "/websocket" resp, err := http.Get(url) require.NoError(t, err) require.Equal(t, resp.StatusCode, http.StatusBadRequest) require.NoError(t, th.TestLogger.Flush()) - testlib.AssertLog(t, th.LogBuffer, mlog.LvlDebug.Name, "Failed to upgrade websocket connection.") + testlib.AssertLog(t, buffer, mlog.LvlDebug.Name, "Failed to upgrade websocket connection.") } diff --git a/server/channels/app/channel.go b/server/channels/app/channel.go index 09fb2ce1cc..162254b84b 100644 --- a/server/channels/app/channel.go +++ b/server/channels/app/channel.go @@ -2518,6 +2518,9 @@ func (a *App) removeUserFromChannel(c request.CTX, userIDToRemove string, remove if err := a.Srv().Store().ChannelMemberHistory().LogLeaveEvent(userIDToRemove, channel.Id, model.GetMillis()); err != nil { return model.NewAppError("removeUserFromChannel", "app.channel_member_history.log_leave_event.internal_error", nil, "", http.StatusInternalServerError).Wrap(err) } + if err := a.Srv().Store().Thread().DeleteMembershipsForChannel(userIDToRemove, channel.Id); err != nil { + return model.NewAppError("removeUserFromChannel", model.NoTranslation, nil, "failed to delete threadmemberships upon leaving channel", http.StatusInternalServerError).Wrap(err) + } if isGuest { currentMembers, err := a.GetChannelMembersForUser(c, channel.TeamId, userIDToRemove) diff --git a/server/channels/app/channel_test.go b/server/channels/app/channel_test.go index eaf1171a6c..2b427ba806 100644 --- a/server/channels/app/channel_test.go +++ b/server/channels/app/channel_test.go @@ -609,6 +609,85 @@ func TestLeaveDefaultChannel(t *testing.T) { _, err = th.App.GetChannelMember(th.Context, townSquare.Id, guest.Id) assert.NotNil(t, err) }) + + t.Run("Trying to leave the default channel should not delete thread memberships", func(t *testing.T) { + post := &model.Post{ + ChannelId: townSquare.Id, + Message: "root post", + UserId: th.BasicUser.Id, + } + rpost, err := th.App.CreatePost(th.Context, post, th.BasicChannel, false, true) + require.Nil(t, err) + + reply := &model.Post{ + ChannelId: townSquare.Id, + Message: "reply post", + UserId: th.BasicUser.Id, + RootId: rpost.Id, + } + _, err = th.App.CreatePost(th.Context, reply, th.BasicChannel, false, true) + require.Nil(t, err) + + threads, err := th.App.GetThreadsForUser(th.BasicUser.Id, townSquare.TeamId, model.GetUserThreadsOpts{}) + require.Nil(t, err) + require.Len(t, threads.Threads, 1) + + err = th.App.LeaveChannel(th.Context, townSquare.Id, th.BasicUser.Id) + assert.NotNil(t, err, "It should fail to remove a regular user from the default channel") + assert.Equal(t, err.Id, "api.channel.remove.default.app_error") + + threads, err = th.App.GetThreadsForUser(th.BasicUser.Id, townSquare.TeamId, model.GetUserThreadsOpts{}) + require.Nil(t, err) + require.Len(t, threads.Threads, 1) + }) +} + +func TestLeaveChannel(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + createThread := func(channel *model.Channel) (rpost *model.Post) { + t.Helper() + post := &model.Post{ + ChannelId: channel.Id, + Message: "root post", + UserId: th.BasicUser.Id, + } + + rpost, err := th.App.CreatePost(th.Context, post, th.BasicChannel, false, true) + require.Nil(t, err) + + reply := &model.Post{ + ChannelId: channel.Id, + Message: "reply post", + UserId: th.BasicUser.Id, + RootId: rpost.Id, + } + _, err = th.App.CreatePost(th.Context, reply, th.BasicChannel, false, true) + require.Nil(t, err) + + return rpost + } + + t.Run("thread memberships are deleted", func(t *testing.T) { + createThread(th.BasicChannel) + channel2 := th.createChannel(th.Context, th.BasicTeam, model.ChannelTypeOpen) + createThread(channel2) + + threads, err := th.App.GetThreadsForUser(th.BasicUser.Id, th.BasicChannel.TeamId, model.GetUserThreadsOpts{}) + require.Nil(t, err) + require.Len(t, threads.Threads, 2) + + err = th.App.LeaveChannel(th.Context, th.BasicChannel.Id, th.BasicUser.Id) + require.Nil(t, err) + + _, err = th.App.GetChannelMember(th.Context, th.BasicChannel.Id, th.BasicUser.Id) + require.NotNil(t, err, "It should remove channel membership") + + threads, err = th.App.GetThreadsForUser(th.BasicUser.Id, th.BasicChannel.TeamId, model.GetUserThreadsOpts{}) + require.Nil(t, err) + require.Len(t, threads.Threads, 1) + }) } func TestLeaveLastChannel(t *testing.T) { diff --git a/server/channels/app/license_test.go b/server/channels/app/license_test.go index 7b32ee52e6..ea6bbcf7eb 100644 --- a/server/channels/app/license_test.go +++ b/server/channels/app/license_test.go @@ -71,8 +71,6 @@ func TestGetSanitizedClientLicense(t *testing.T) { assert.False(t, ok) _, ok = m["SkuName"] assert.False(t, ok) - _, ok = m["SkuShortName"] - assert.False(t, ok) } func TestGenerateRenewalToken(t *testing.T) { diff --git a/server/channels/app/onboarding.go b/server/channels/app/onboarding.go index 2dd85749d9..3b76aefe53 100644 --- a/server/channels/app/onboarding.go +++ b/server/channels/app/onboarding.go @@ -28,6 +28,24 @@ func (a *App) markAdminOnboardingComplete(c *request.Context) *model.AppError { } func (a *App) CompleteOnboarding(c *request.Context, request *model.CompleteOnboardingRequest) *model.AppError { + isCloud := a.Srv().License() != nil && *a.Srv().License().Features.Cloud + + if !isCloud && request.Organization == "" { + mlog.Error("No organization name provided for self hosted onboarding") + return model.NewAppError("CompleteOnboarding", "api.error_no_organization_name_provided_for_self_hosted_onboarding", nil, "", http.StatusBadRequest) + } + + if request.Organization != "" { + err := a.Srv().Store().System().SaveOrUpdate(&model.System{ + Name: model.SystemOrganizationName, + Value: request.Organization, + }) + if err != nil { + // don't block onboarding because of that. + a.Log().Error("failed to save organization name", mlog.Err(err)) + } + } + pluginsEnvironment := a.Channels().GetPluginsEnvironment() if pluginsEnvironment == nil { return a.markAdminOnboardingComplete(c) diff --git a/server/channels/app/onboarding_test.go b/server/channels/app/onboarding_test.go new file mode 100644 index 0000000000..cf8462cf28 --- /dev/null +++ b/server/channels/app/onboarding_test.go @@ -0,0 +1,30 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package app + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mattermost/mattermost-server/server/v8/channels/app/request" + mm_model "github.com/mattermost/mattermost-server/server/v8/model" +) + +func TestOnboardingSavesOrganizationName(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + err := th.App.CompleteOnboarding(&request.Context{}, &mm_model.CompleteOnboardingRequest{ + Organization: "Mattermost In Tests", + }) + require.Nil(t, err) + defer func() { + th.App.Srv().Store().System().PermanentDeleteByName(mm_model.SystemOrganizationName) + }() + + sys, storeErr := th.App.Srv().Store().System().GetByName(mm_model.SystemOrganizationName) + require.NoError(t, storeErr) + require.Equal(t, "Mattermost In Tests", sys.Value) +} diff --git a/server/channels/app/platform/license_test.go b/server/channels/app/platform/license_test.go index 6682348130..258c2fbe38 100644 --- a/server/channels/app/platform/license_test.go +++ b/server/channels/app/platform/license_test.go @@ -71,8 +71,6 @@ func TestGetSanitizedClientLicense(t *testing.T) { assert.False(t, ok) _, ok = m["SkuName"] assert.False(t, ok) - _, ok = m["SkuShortName"] - assert.False(t, ok) } func TestGenerateRenewalToken(t *testing.T) { diff --git a/server/channels/db/migrations/migrations.list b/server/channels/db/migrations/migrations.list index 6a7d33d5c6..47f5bf333b 100644 --- a/server/channels/db/migrations/migrations.list +++ b/server/channels/db/migrations/migrations.list @@ -212,6 +212,8 @@ channels/db/migrations/mysql/000105_remove_tokens.down.sql channels/db/migrations/mysql/000105_remove_tokens.up.sql channels/db/migrations/mysql/000106_fileinfo_channelid.down.sql channels/db/migrations/mysql/000106_fileinfo_channelid.up.sql +channels/db/migrations/mysql/000107_threadmemberships_cleanup.down.sql +channels/db/migrations/mysql/000107_threadmemberships_cleanup.up.sql channels/db/migrations/postgres/000001_create_teams.down.sql channels/db/migrations/postgres/000001_create_teams.up.sql channels/db/migrations/postgres/000002_create_team_members.down.sql @@ -424,3 +426,5 @@ channels/db/migrations/postgres/000105_remove_tokens.down.sql channels/db/migrations/postgres/000105_remove_tokens.up.sql channels/db/migrations/postgres/000106_fileinfo_channelid.down.sql channels/db/migrations/postgres/000106_fileinfo_channelid.up.sql +channels/db/migrations/postgres/000107_threadmemberships_cleanup.down.sql +channels/db/migrations/postgres/000107_threadmemberships_cleanup.up.sql diff --git a/server/channels/db/migrations/mysql/000107_threadmemberships_cleanup.down.sql b/server/channels/db/migrations/mysql/000107_threadmemberships_cleanup.down.sql new file mode 100644 index 0000000000..4743bd6462 --- /dev/null +++ b/server/channels/db/migrations/mysql/000107_threadmemberships_cleanup.down.sql @@ -0,0 +1 @@ +-- Skipping it because the forward migrations are destructive diff --git a/server/channels/db/migrations/mysql/000107_threadmemberships_cleanup.up.sql b/server/channels/db/migrations/mysql/000107_threadmemberships_cleanup.up.sql new file mode 100644 index 0000000000..90644be3f3 --- /dev/null +++ b/server/channels/db/migrations/mysql/000107_threadmemberships_cleanup.up.sql @@ -0,0 +1,5 @@ +DELETE FROM + tm USING ThreadMemberships AS tm + JOIN Threads ON Threads.PostId = tm.PostId +WHERE + (tm.UserId, Threads.ChannelId) NOT IN (SELECT UserId, ChannelId FROM ChannelMembers); diff --git a/server/channels/db/migrations/postgres/000107_threadmemberships_cleanup.down.sql b/server/channels/db/migrations/postgres/000107_threadmemberships_cleanup.down.sql new file mode 100644 index 0000000000..4743bd6462 --- /dev/null +++ b/server/channels/db/migrations/postgres/000107_threadmemberships_cleanup.down.sql @@ -0,0 +1 @@ +-- Skipping it because the forward migrations are destructive diff --git a/server/channels/db/migrations/postgres/000107_threadmemberships_cleanup.up.sql b/server/channels/db/migrations/postgres/000107_threadmemberships_cleanup.up.sql new file mode 100644 index 0000000000..0ec82905bc --- /dev/null +++ b/server/channels/db/migrations/postgres/000107_threadmemberships_cleanup.up.sql @@ -0,0 +1,12 @@ +DELETE FROM threadmemberships WHERE (postid, userid) IN ( + SELECT + threadmemberships.postid, + threadmemberships.userid + FROM + threadmemberships + JOIN threads ON threads.postid = threadmemberships.postid + LEFT JOIN channelmembers ON channelmembers.userid = threadmemberships.userid + AND threads.channelid = channelmembers.channelid + WHERE + channelmembers.channelid IS NULL +); diff --git a/server/channels/einterfaces/cloud.go b/server/channels/einterfaces/cloud.go index 231afc0d49..1c0878f6ef 100644 --- a/server/channels/einterfaces/cloud.go +++ b/server/channels/einterfaces/cloud.go @@ -37,6 +37,7 @@ type CloudInterface interface { BootstrapSelfHostedSignup(req model.BootstrapSelfHostedSignupRequest) (*model.BootstrapSelfHostedSignupResponse, error) CreateCustomerSelfHostedSignup(req model.SelfHostedCustomerForm, requesterEmail string) (*model.SelfHostedSignupCustomerResponse, error) ConfirmSelfHostedSignup(req model.SelfHostedConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error) + ConfirmSelfHostedExpansion(req model.SelfHostedConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error) ConfirmSelfHostedSignupLicenseApplication() error GetSelfHostedInvoices() ([]*model.Invoice, error) GetSelfHostedInvoicePDF(invoiceID string) ([]byte, string, error) diff --git a/server/channels/einterfaces/metrics.go b/server/channels/einterfaces/metrics.go index 06f44f7b66..c44af2a3c5 100644 --- a/server/channels/einterfaces/metrics.go +++ b/server/channels/einterfaces/metrics.go @@ -13,6 +13,7 @@ import ( type MetricsInterface interface { Register() RegisterDBCollector(db *sql.DB, name string) + UnregisterDBCollector(db *sql.DB, name string) IncrementPostCreate() IncrementWebhookPost() diff --git a/server/channels/einterfaces/mocks/CloudInterface.go b/server/channels/einterfaces/mocks/CloudInterface.go index 03d084411e..66d8bc3c9e 100644 --- a/server/channels/einterfaces/mocks/CloudInterface.go +++ b/server/channels/einterfaces/mocks/CloudInterface.go @@ -94,6 +94,32 @@ func (_m *CloudInterface) ConfirmCustomerPayment(userID string, confirmRequest * return r0 } +// ConfirmSelfHostedExpansion provides a mock function with given fields: req, requesterEmail +func (_m *CloudInterface) ConfirmSelfHostedExpansion(req model.SelfHostedConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error) { + ret := _m.Called(req, requesterEmail) + + var r0 *model.SelfHostedSignupConfirmResponse + var r1 error + if rf, ok := ret.Get(0).(func(model.SelfHostedConfirmPaymentMethodRequest, string) (*model.SelfHostedSignupConfirmResponse, error)); ok { + return rf(req, requesterEmail) + } + if rf, ok := ret.Get(0).(func(model.SelfHostedConfirmPaymentMethodRequest, string) *model.SelfHostedSignupConfirmResponse); ok { + r0 = rf(req, requesterEmail) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.SelfHostedSignupConfirmResponse) + } + } + + if rf, ok := ret.Get(1).(func(model.SelfHostedConfirmPaymentMethodRequest, string) error); ok { + r1 = rf(req, requesterEmail) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // ConfirmSelfHostedSignup provides a mock function with given fields: req, requesterEmail func (_m *CloudInterface) ConfirmSelfHostedSignup(req model.SelfHostedConfirmPaymentMethodRequest, requesterEmail string) (*model.SelfHostedSignupConfirmResponse, error) { ret := _m.Called(req, requesterEmail) diff --git a/server/channels/einterfaces/mocks/MetricsInterface.go b/server/channels/einterfaces/mocks/MetricsInterface.go index 0d6f799ee5..06f568546a 100644 --- a/server/channels/einterfaces/mocks/MetricsInterface.go +++ b/server/channels/einterfaces/mocks/MetricsInterface.go @@ -319,6 +319,11 @@ func (_m *MetricsInterface) SetReplicaLagTime(node string, value float64) { _m.Called(node, value) } +// UnregisterDBCollector provides a mock function with given fields: db, name +func (_m *MetricsInterface) UnregisterDBCollector(db *sql.DB, name string) { + _m.Called(db, name) +} + type mockConstructorTestingTNewMetricsInterface interface { mock.TestingT Cleanup(func()) diff --git a/server/channels/store/opentracinglayer/opentracinglayer.go b/server/channels/store/opentracinglayer/opentracinglayer.go index 941704a2f4..66a5051260 100644 --- a/server/channels/store/opentracinglayer/opentracinglayer.go +++ b/server/channels/store/opentracinglayer/opentracinglayer.go @@ -10123,6 +10123,24 @@ func (s *OpenTracingLayerThreadStore) DeleteMembershipForUser(userId string, pos return err } +func (s *OpenTracingLayerThreadStore) DeleteMembershipsForChannel(userID string, channelID string) error { + origCtx := s.Root.Store.Context() + span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.DeleteMembershipsForChannel") + s.Root.Store.SetContext(newCtx) + defer func() { + s.Root.Store.SetContext(origCtx) + }() + + defer span.Finish() + err := s.ThreadStore.DeleteMembershipsForChannel(userID, channelID) + if err != nil { + span.LogFields(spanlog.Error(err)) + ext.Error.Set(span, true) + } + + return err +} + func (s *OpenTracingLayerThreadStore) DeleteOrphanedRows(limit int) (int64, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "ThreadStore.DeleteOrphanedRows") diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 91a3209c44..b39c79ab9b 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -11563,6 +11563,27 @@ func (s *RetryLayerThreadStore) DeleteMembershipForUser(userId string, postID st } +func (s *RetryLayerThreadStore) DeleteMembershipsForChannel(userID string, channelID string) error { + + tries := 0 + for { + err := s.ThreadStore.DeleteMembershipsForChannel(userID, channelID) + if err == nil { + return nil + } + if !isRepeatableError(err) { + return err + } + tries++ + if tries >= 3 { + err = errors.Wrap(err, "giving up after 3 consecutive repeatable transaction failures") + return err + } + timepkg.Sleep(100 * timepkg.Millisecond) + } + +} + func (s *RetryLayerThreadStore) DeleteOrphanedRows(limit int) (int64, error) { tries := 0 diff --git a/server/channels/store/sqlstore/channel_store_categories.go b/server/channels/store/sqlstore/channel_store_categories.go index 3f2f726a0f..4eca0d5de0 100644 --- a/server/channels/store/sqlstore/channel_store_categories.go +++ b/server/channels/store/sqlstore/channel_store_categories.go @@ -335,7 +335,7 @@ func (s SqlChannelStore) CreateSidebarCategory(userId, teamId string, newCategor Id: newCategoryId, UserId: userId, TeamId: teamId, - Sorting: model.SidebarCategorySortDefault, + Sorting: newCategory.Sorting, SortOrder: int64(model.MinimalSidebarSortDistance * len(newOrder)), // first we place it at the end of the list Type: model.SidebarCategoryCustom, Muted: newCategory.Muted, diff --git a/server/channels/store/sqlstore/sqlx_wrapper.go b/server/channels/store/sqlstore/sqlx_wrapper.go index 0dab579512..e8d771cada 100644 --- a/server/channels/store/sqlstore/sqlx_wrapper.go +++ b/server/channels/store/sqlstore/sqlx_wrapper.go @@ -6,9 +6,12 @@ package sqlstore import ( "context" "database/sql" + "errors" + "net" "regexp" "strconv" "strings" + "sync/atomic" "time" "unicode" @@ -66,14 +69,18 @@ type sqlxDBWrapper struct { *sqlx.DB queryTimeout time.Duration trace bool + isOnline *atomic.Bool } func newSqlxDBWrapper(db *sqlx.DB, timeout time.Duration, trace bool) *sqlxDBWrapper { - return &sqlxDBWrapper{ + w := &sqlxDBWrapper{ DB: db, queryTimeout: timeout, trace: trace, + isOnline: &atomic.Bool{}, } + w.isOnline.Store(true) + return w } func (w *sqlxDBWrapper) Stats() sql.DBStats { @@ -83,19 +90,19 @@ func (w *sqlxDBWrapper) Stats() sql.DBStats { func (w *sqlxDBWrapper) Beginx() (*sqlxTxWrapper, error) { tx, err := w.DB.Beginx() if err != nil { - return nil, err + return nil, w.checkErr(err) } - return newSqlxTxWrapper(tx, w.queryTimeout, w.trace), nil + return newSqlxTxWrapper(tx, w.queryTimeout, w.trace, w), nil } func (w *sqlxDBWrapper) BeginXWithIsolation(opts *sql.TxOptions) (*sqlxTxWrapper, error) { tx, err := w.DB.BeginTxx(context.Background(), opts) if err != nil { - return nil, err + return nil, w.checkErr(err) } - return newSqlxTxWrapper(tx, w.queryTimeout, w.trace), nil + return newSqlxTxWrapper(tx, w.queryTimeout, w.trace, w), nil } func (w *sqlxDBWrapper) Get(dest any, query string, args ...any) error { @@ -109,7 +116,7 @@ func (w *sqlxDBWrapper) Get(dest any, query string, args ...any) error { }(time.Now()) } - return w.DB.GetContext(ctx, dest, query, args...) + return w.checkErr(w.DB.GetContext(ctx, dest, query, args...)) } func (w *sqlxDBWrapper) GetBuilder(dest any, builder Builder) error { @@ -134,7 +141,7 @@ func (w *sqlxDBWrapper) NamedExec(query string, arg any) (sql.Result, error) { }(time.Now()) } - return w.DB.NamedExecContext(ctx, query, arg) + return w.checkErrWithResult(w.DB.NamedExecContext(ctx, query, arg)) } func (w *sqlxDBWrapper) Exec(query string, args ...any) (sql.Result, error) { @@ -161,7 +168,7 @@ func (w *sqlxDBWrapper) ExecNoTimeout(query string, args ...any) (sql.Result, er }(time.Now()) } - return w.DB.ExecContext(context.Background(), query, args...) + return w.checkErrWithResult(w.DB.ExecContext(context.Background(), query, args...)) } // ExecRaw is like Exec but without any rebinding of params. You need to pass @@ -176,7 +183,7 @@ func (w *sqlxDBWrapper) ExecRaw(query string, args ...any) (sql.Result, error) { }(time.Now()) } - return w.DB.ExecContext(ctx, query, args...) + return w.checkErrWithResult(w.DB.ExecContext(ctx, query, args...)) } func (w *sqlxDBWrapper) NamedQuery(query string, arg any) (*sqlx.Rows, error) { @@ -192,7 +199,7 @@ func (w *sqlxDBWrapper) NamedQuery(query string, arg any) (*sqlx.Rows, error) { }(time.Now()) } - return w.DB.NamedQueryContext(ctx, query, arg) + return w.checkErrWithRows(w.DB.NamedQueryContext(ctx, query, arg)) } func (w *sqlxDBWrapper) QueryRowX(query string, args ...any) *sqlx.Row { @@ -220,7 +227,7 @@ func (w *sqlxDBWrapper) QueryX(query string, args ...any) (*sqlx.Rows, error) { }(time.Now()) } - return w.DB.QueryxContext(ctx, query, args) + return w.checkErrWithRows(w.DB.QueryxContext(ctx, query, args)) } func (w *sqlxDBWrapper) Select(dest any, query string, args ...any) error { @@ -238,7 +245,7 @@ func (w *sqlxDBWrapper) SelectCtx(ctx context.Context, dest any, query string, a }(time.Now()) } - return w.DB.SelectContext(ctx, dest, query, args...) + return w.checkErr(w.DB.SelectContext(ctx, dest, query, args...)) } func (w *sqlxDBWrapper) SelectBuilder(dest any, builder Builder) error { @@ -254,13 +261,15 @@ type sqlxTxWrapper struct { *sqlx.Tx queryTimeout time.Duration trace bool + dbw *sqlxDBWrapper } -func newSqlxTxWrapper(tx *sqlx.Tx, timeout time.Duration, trace bool) *sqlxTxWrapper { +func newSqlxTxWrapper(tx *sqlx.Tx, timeout time.Duration, trace bool, dbw *sqlxDBWrapper) *sqlxTxWrapper { return &sqlxTxWrapper{ Tx: tx, queryTimeout: timeout, trace: trace, + dbw: dbw, } } @@ -275,7 +284,7 @@ func (w *sqlxTxWrapper) Get(dest any, query string, args ...any) error { }(time.Now()) } - return w.Tx.GetContext(ctx, dest, query, args...) + return w.dbw.checkErr(w.Tx.GetContext(ctx, dest, query, args...)) } func (w *sqlxTxWrapper) GetBuilder(dest any, builder Builder) error { @@ -284,13 +293,13 @@ func (w *sqlxTxWrapper) GetBuilder(dest any, builder Builder) error { return err } - return w.Get(dest, query, args...) + return w.dbw.checkErr(w.Get(dest, query, args...)) } func (w *sqlxTxWrapper) Exec(query string, args ...any) (sql.Result, error) { query = w.Tx.Rebind(query) - return w.ExecRaw(query, args...) + return w.dbw.checkErrWithResult(w.ExecRaw(query, args...)) } func (w *sqlxTxWrapper) ExecNoTimeout(query string, args ...any) (sql.Result, error) { @@ -302,7 +311,7 @@ func (w *sqlxTxWrapper) ExecNoTimeout(query string, args ...any) (sql.Result, er }(time.Now()) } - return w.Tx.ExecContext(context.Background(), query, args...) + return w.dbw.checkErrWithResult(w.Tx.ExecContext(context.Background(), query, args...)) } func (w *sqlxTxWrapper) ExecBuilder(builder Builder) (sql.Result, error) { @@ -326,7 +335,7 @@ func (w *sqlxTxWrapper) ExecRaw(query string, args ...any) (sql.Result, error) { }(time.Now()) } - return w.Tx.ExecContext(ctx, query, args...) + return w.dbw.checkErrWithResult(w.Tx.ExecContext(ctx, query, args...)) } func (w *sqlxTxWrapper) NamedExec(query string, arg any) (sql.Result, error) { @@ -342,7 +351,7 @@ func (w *sqlxTxWrapper) NamedExec(query string, arg any) (sql.Result, error) { }(time.Now()) } - return w.Tx.NamedExecContext(ctx, query, arg) + return w.dbw.checkErrWithResult(w.Tx.NamedExecContext(ctx, query, arg)) } func (w *sqlxTxWrapper) NamedQuery(query string, arg any) (*sqlx.Rows, error) { @@ -386,7 +395,7 @@ func (w *sqlxTxWrapper) NamedQuery(query string, arg any) (*sqlx.Rows, error) { } } - return res.rows, res.err + return res.rows, w.dbw.checkErr(res.err) } func (w *sqlxTxWrapper) QueryRowX(query string, args ...any) *sqlx.Row { @@ -414,7 +423,7 @@ func (w *sqlxTxWrapper) QueryX(query string, args ...any) (*sqlx.Rows, error) { }(time.Now()) } - return w.Tx.QueryxContext(ctx, query, args) + return w.dbw.checkErrWithRows(w.Tx.QueryxContext(ctx, query, args)) } func (w *sqlxTxWrapper) Select(dest any, query string, args ...any) error { @@ -428,7 +437,7 @@ func (w *sqlxTxWrapper) Select(dest any, query string, args ...any) error { }(time.Now()) } - return w.Tx.SelectContext(ctx, dest, query, args...) + return w.dbw.checkErr(w.Tx.SelectContext(ctx, dest, query, args...)) } func (w *sqlxTxWrapper) SelectBuilder(dest any, builder Builder) error { @@ -459,3 +468,23 @@ func printArgs(query string, dur time.Duration, args ...any) { } mlog.Debug(query, fields...) } + +func (w *sqlxDBWrapper) checkErrWithResult(res sql.Result, err error) (sql.Result, error) { + return res, w.checkErr(err) +} + +func (w *sqlxDBWrapper) checkErrWithRows(res *sqlx.Rows, err error) (*sqlx.Rows, error) { + return res, w.checkErr(err) +} + +func (w *sqlxDBWrapper) checkErr(err error) error { + var netError *net.OpError + if errors.As(err, &netError) && (!netError.Temporary() && !netError.Timeout()) { + w.isOnline.Store(false) + } + return err +} + +func (w *sqlxDBWrapper) Online() bool { + return w.isOnline.Load() +} diff --git a/server/channels/store/sqlstore/sqlx_wrapper_test.go b/server/channels/store/sqlstore/sqlx_wrapper_test.go index 07c6391767..c03d228935 100644 --- a/server/channels/store/sqlstore/sqlx_wrapper_test.go +++ b/server/channels/store/sqlstore/sqlx_wrapper_test.go @@ -6,6 +6,7 @@ package sqlstore import ( "context" "strings" + "sync" "testing" "github.com/stretchr/testify/assert" @@ -28,12 +29,14 @@ func TestSqlX(t *testing.T) { } *settings.QueryTimeout = 1 store := &SqlStore{ - rrCounter: 0, - srCounter: 0, - settings: settings, + rrCounter: 0, + srCounter: 0, + settings: settings, + quitMonitor: make(chan struct{}), + wgMonitor: &sync.WaitGroup{}, } - store.initConnection() + require.NoError(t, store.initConnection()) defer store.Close() diff --git a/server/channels/store/sqlstore/store.go b/server/channels/store/sqlstore/store.go index d39f92661c..acd02b0853 100644 --- a/server/channels/store/sqlstore/store.go +++ b/server/channels/store/sqlstore/store.go @@ -49,7 +49,7 @@ const ( MySQLForeignKeyViolationErrorCode = 1452 PGDuplicateObjectErrorCode = "42710" MySQLDuplicateObjectErrorCode = 1022 - DBPingAttempts = 18 + DBPingAttempts = 5 DBPingTimeoutSecs = 10 // This is a numerical version string by postgres. The format is // 2 characters for major, minor, and patch version prior to 10. @@ -123,9 +123,9 @@ type SqlStore struct { masterX *sqlxDBWrapper - ReplicaXs []*sqlxDBWrapper + ReplicaXs []*atomic.Pointer[sqlxDBWrapper] - searchReplicaXs []*sqlxDBWrapper + searchReplicaXs []*atomic.Pointer[sqlxDBWrapper] replicaLagHandles []*dbsql.DB stores SqlStoreStores @@ -138,17 +138,28 @@ type SqlStore struct { isBinaryParam bool pgDefaultTextSearchConfig string + + quitMonitor chan struct{} + wgMonitor *sync.WaitGroup } func New(settings model.SqlSettings, metrics einterfaces.MetricsInterface) *SqlStore { store := &SqlStore{ - rrCounter: 0, - srCounter: 0, - settings: &settings, - metrics: metrics, + rrCounter: 0, + srCounter: 0, + settings: &settings, + metrics: metrics, + quitMonitor: make(chan struct{}), + wgMonitor: &sync.WaitGroup{}, } - store.initConnection() + err := store.initConnection() + if err != nil { + mlog.Fatal("Error setting up connections", mlog.Err(err)) + } + + store.wgMonitor.Add(1) + go store.monitorReplicas() ver, err := store.GetDbVersion(true) if err != nil { @@ -230,29 +241,28 @@ func New(settings model.SqlSettings, metrics einterfaces.MetricsInterface) *SqlS // SetupConnection sets up the connection to the database and pings it to make sure it's alive. // It also applies any database configuration settings that are required. -func SetupConnection(connType string, dataSource string, settings *model.SqlSettings) *dbsql.DB { +func SetupConnection(connType string, dataSource string, settings *model.SqlSettings, attempts int) (*dbsql.DB, error) { db, err := dbsql.Open(*settings.DriverName, dataSource) if err != nil { - mlog.Fatal("Failed to open SQL connection to err.", mlog.Err(err)) + return nil, errors.Wrap(err, "failed to open SQL connection") } - for i := 0; i < DBPingAttempts; i++ { + for i := 0; i < attempts; i++ { // At this point, we have passed sql.Open, so we deliberately ignore any errors. sanitized, _ := SanitizeDataSource(*settings.DriverName, dataSource) mlog.Info("Pinging SQL", mlog.String("database", connType), mlog.String("dataSource", sanitized)) ctx, cancel := context.WithTimeout(context.Background(), DBPingTimeoutSecs*time.Second) defer cancel() err = db.PingContext(ctx) - if err == nil { - break - } else { - if i == DBPingAttempts-1 { - mlog.Fatal("Failed to ping DB, server will exit.", mlog.Err(err)) - } else { - mlog.Error("Failed to ping DB", mlog.Err(err), mlog.Int("retrying in seconds", DBPingTimeoutSecs)) - time.Sleep(DBPingTimeoutSecs * time.Second) + if err != nil { + if i == attempts-1 { + return nil, err } + mlog.Error("Failed to ping DB", mlog.Err(err), mlog.Int("retrying in seconds", DBPingTimeoutSecs)) + time.Sleep(DBPingTimeoutSecs * time.Second) + continue } + break } if strings.HasPrefix(connType, replicaLagPrefix) { @@ -272,7 +282,7 @@ func SetupConnection(connType string, dataSource string, settings *model.SqlSett db.SetConnMaxLifetime(time.Duration(*settings.ConnMaxLifetimeMilliseconds) * time.Millisecond) db.SetConnMaxIdleTime(time.Duration(*settings.ConnMaxIdleTimeMilliseconds) * time.Millisecond) - return db + return db, nil } func (ss *SqlStore) SetContext(context context.Context) { @@ -285,7 +295,7 @@ func (ss *SqlStore) Context() context.Context { func noOpMapper(s string) string { return s } -func (ss *SqlStore) initConnection() { +func (ss *SqlStore) initConnection() error { dataSource := *ss.settings.DataSource if ss.DriverName() == model.DatabaseDriverMysql { // TODO: We ignore the readTimeout datasource parameter for MySQL since QueryTimeout @@ -294,11 +304,14 @@ func (ss *SqlStore) initConnection() { var err error dataSource, err = ResetReadTimeout(dataSource) if err != nil { - mlog.Fatal("Failed to reset read timeout from datasource.", mlog.Err(err), mlog.String("src", dataSource)) + return errors.Wrap(err, "failed to reset read timeout from datasource") } } - handle := SetupConnection("master", dataSource, ss.settings) + handle, err := SetupConnection("master", dataSource, ss.settings, DBPingAttempts) + if err != nil { + return err + } ss.masterX = newSqlxDBWrapper(sqlx.NewDb(handle, ss.DriverName()), time.Duration(*ss.settings.QueryTimeout)*time.Second, *ss.settings.Trace) @@ -310,34 +323,32 @@ func (ss *SqlStore) initConnection() { } if len(ss.settings.DataSourceReplicas) > 0 { - ss.ReplicaXs = make([]*sqlxDBWrapper, len(ss.settings.DataSourceReplicas)) + ss.ReplicaXs = make([]*atomic.Pointer[sqlxDBWrapper], len(ss.settings.DataSourceReplicas)) for i, replica := range ss.settings.DataSourceReplicas { - handle := SetupConnection(fmt.Sprintf("replica-%v", i), replica, ss.settings) - ss.ReplicaXs[i] = newSqlxDBWrapper(sqlx.NewDb(handle, ss.DriverName()), - time.Duration(*ss.settings.QueryTimeout)*time.Second, - *ss.settings.Trace) - if ss.DriverName() == model.DatabaseDriverMysql { - ss.ReplicaXs[i].MapperFunc(noOpMapper) - } - if ss.metrics != nil { - ss.metrics.RegisterDBCollector(ss.ReplicaXs[i].DB.DB, "replica-"+strconv.Itoa(i)) + ss.ReplicaXs[i] = &atomic.Pointer[sqlxDBWrapper]{} + handle, err = SetupConnection(fmt.Sprintf("replica-%v", i), replica, ss.settings, DBPingAttempts) + if err != nil { + // Initializing to be offline + ss.ReplicaXs[i].Store(&sqlxDBWrapper{isOnline: &atomic.Bool{}}) + mlog.Warn("Failed to setup connection. Skipping..", mlog.String("db", fmt.Sprintf("replica-%v", i)), mlog.Err(err)) + continue } + ss.setDB(ss.ReplicaXs[i], handle, "replica-"+strconv.Itoa(i)) } } if len(ss.settings.DataSourceSearchReplicas) > 0 { - ss.searchReplicaXs = make([]*sqlxDBWrapper, len(ss.settings.DataSourceSearchReplicas)) + ss.searchReplicaXs = make([]*atomic.Pointer[sqlxDBWrapper], len(ss.settings.DataSourceSearchReplicas)) for i, replica := range ss.settings.DataSourceSearchReplicas { - handle := SetupConnection(fmt.Sprintf("search-replica-%v", i), replica, ss.settings) - ss.searchReplicaXs[i] = newSqlxDBWrapper(sqlx.NewDb(handle, ss.DriverName()), - time.Duration(*ss.settings.QueryTimeout)*time.Second, - *ss.settings.Trace) - if ss.DriverName() == model.DatabaseDriverMysql { - ss.searchReplicaXs[i].MapperFunc(noOpMapper) - } - if ss.metrics != nil { - ss.metrics.RegisterDBCollector(ss.searchReplicaXs[i].DB.DB, "searchreplica-"+strconv.Itoa(i)) + ss.searchReplicaXs[i] = &atomic.Pointer[sqlxDBWrapper]{} + handle, err = SetupConnection(fmt.Sprintf("search-replica-%v", i), replica, ss.settings, DBPingAttempts) + if err != nil { + // Initializing to be offline + ss.searchReplicaXs[i].Store(&sqlxDBWrapper{isOnline: &atomic.Bool{}}) + mlog.Warn("Failed to setup connection. Skipping..", mlog.String("db", fmt.Sprintf("search-replica-%v", i)), mlog.Err(err)) + continue } + ss.setDB(ss.searchReplicaXs[i], handle, "searchreplica-"+strconv.Itoa(i)) } } @@ -347,9 +358,14 @@ func (ss *SqlStore) initConnection() { if src.DataSource == nil { continue } - ss.replicaLagHandles[i] = SetupConnection(fmt.Sprintf(replicaLagPrefix+"-%d", i), *src.DataSource, ss.settings) + ss.replicaLagHandles[i], err = SetupConnection(fmt.Sprintf(replicaLagPrefix+"-%d", i), *src.DataSource, ss.settings, DBPingAttempts) + if err != nil { + mlog.Warn("Failed to setup replica lag handle. Skipping..", mlog.String("db", fmt.Sprintf(replicaLagPrefix+"-%d", i)), mlog.Err(err)) + continue + } } } + return nil } func (ss *SqlStore) DriverName() string { @@ -455,8 +471,15 @@ func (ss *SqlStore) GetSearchReplicaX() *sqlxDBWrapper { return ss.GetReplicaX() } - rrNum := atomic.AddInt64(&ss.srCounter, 1) % int64(len(ss.searchReplicaXs)) - return ss.searchReplicaXs[rrNum] + for i := 0; i < len(ss.searchReplicaXs); i++ { + rrNum := atomic.AddInt64(&ss.srCounter, 1) % int64(len(ss.searchReplicaXs)) + if ss.searchReplicaXs[rrNum].Load().Online() { + return ss.searchReplicaXs[rrNum].Load() + } + } + + // If all search replicas are down, then go with replica. + return ss.GetReplicaX() } func (ss *SqlStore) GetReplicaX() *sqlxDBWrapper { @@ -464,23 +487,64 @@ func (ss *SqlStore) GetReplicaX() *sqlxDBWrapper { return ss.GetMasterX() } - rrNum := atomic.AddInt64(&ss.rrCounter, 1) % int64(len(ss.ReplicaXs)) - return ss.ReplicaXs[rrNum] -} - -func (ss *SqlStore) GetInternalReplicaDBs() []*sql.DB { - if len(ss.settings.DataSourceReplicas) == 0 || ss.lockedToMaster || !ss.hasLicense() { - return []*sql.DB{ - ss.GetMasterX().DB.DB, + for i := 0; i < len(ss.ReplicaXs); i++ { + rrNum := atomic.AddInt64(&ss.rrCounter, 1) % int64(len(ss.ReplicaXs)) + if ss.ReplicaXs[rrNum].Load().Online() { + return ss.ReplicaXs[rrNum].Load() } } - dbs := make([]*sql.DB, len(ss.ReplicaXs)) - for i, rx := range ss.ReplicaXs { - dbs[i] = rx.DB.DB - } + // If all replicas are down, then go with master. + return ss.GetMasterX() +} - return dbs +func (ss *SqlStore) monitorReplicas() { + t := time.NewTicker(time.Duration(*ss.settings.ReplicaMonitorIntervalSeconds) * time.Second) + defer func() { + t.Stop() + ss.wgMonitor.Done() + }() + for { + select { + case <-ss.quitMonitor: + return + case <-t.C: + setupReplica := func(r *atomic.Pointer[sqlxDBWrapper], dsn, name string) { + if r.Load().Online() { + return + } + + handle, err := SetupConnection(name, dsn, ss.settings, 1) + if err != nil { + mlog.Warn("Failed to setup connection. Skipping..", mlog.String("db", name), mlog.Err(err)) + return + } + if ss.metrics != nil && r.Load() != nil && r.Load().DB != nil { + ss.metrics.UnregisterDBCollector(r.Load().DB.DB, name) + } + ss.setDB(r, handle, name) + } + for i, replica := range ss.ReplicaXs { + setupReplica(replica, ss.settings.DataSourceReplicas[i], "replica-"+strconv.Itoa(i)) + } + + for i, replica := range ss.searchReplicaXs { + setupReplica(replica, ss.settings.DataSourceSearchReplicas[i], "search-replica-"+strconv.Itoa(i)) + } + } + } +} + +func (ss *SqlStore) setDB(replica *atomic.Pointer[sqlxDBWrapper], handle *dbsql.DB, name string) { + replica.Store(newSqlxDBWrapper(sqlx.NewDb(handle, ss.DriverName()), + time.Duration(*ss.settings.QueryTimeout)*time.Second, + *ss.settings.Trace)) + if ss.DriverName() == model.DatabaseDriverMysql { + replica.Load().MapperFunc(noOpMapper) + } + if ss.metrics != nil { + ss.metrics.RegisterDBCollector(replica.Load().DB.DB, name) + } } func (ss *SqlStore) GetInternalReplicaDB() *sql.DB { @@ -489,7 +553,7 @@ func (ss *SqlStore) GetInternalReplicaDB() *sql.DB { } rrNum := atomic.AddInt64(&ss.rrCounter, 1) % int64(len(ss.ReplicaXs)) - return ss.ReplicaXs[rrNum].DB.DB + return ss.ReplicaXs[rrNum].Load().DB.DB } func (ss *SqlStore) TotalMasterDbConnections() int { @@ -541,7 +605,10 @@ func (ss *SqlStore) TotalReadDbConnections() int { count := 0 for _, db := range ss.ReplicaXs { - count = count + db.Stats().OpenConnections + if !db.Load().Online() { + continue + } + count = count + db.Load().Stats().OpenConnections } return count @@ -554,7 +621,10 @@ func (ss *SqlStore) TotalSearchDbConnections() int { count := 0 for _, db := range ss.searchReplicaXs { - count = count + db.Stats().OpenConnections + if !db.Load().Online() { + continue + } + count = count + db.Load().Stats().OpenConnections } return count @@ -782,9 +852,14 @@ func IsUniqueConstraintError(err error, indexName []string) bool { } func (ss *SqlStore) GetAllConns() []*sqlxDBWrapper { - all := make([]*sqlxDBWrapper, len(ss.ReplicaXs)+1) - copy(all, ss.ReplicaXs) - all[len(ss.ReplicaXs)] = ss.masterX + all := make([]*sqlxDBWrapper, 0, len(ss.ReplicaXs)+1) + for i := range ss.ReplicaXs { + if !ss.ReplicaXs[i].Load().Online() { + continue + } + all = append(all, ss.ReplicaXs[i].Load()) + } + all = append(all, ss.masterX) return all } @@ -807,11 +882,24 @@ func (ss *SqlStore) RecycleDBConnections(d time.Duration) { func (ss *SqlStore) Close() { ss.masterX.Close() + // Closing monitor and waiting for it to be done. + // This needs to be done before closing the replica handles. + close(ss.quitMonitor) + ss.wgMonitor.Wait() + for _, replica := range ss.ReplicaXs { - replica.Close() + if replica.Load().Online() { + replica.Load().Close() + } } for _, replica := range ss.searchReplicaXs { + if replica.Load().Online() { + replica.Load().Close() + } + } + + for _, replica := range ss.replicaLagHandles { replica.Close() } } @@ -1132,7 +1220,10 @@ func (ss *SqlStore) migrate(direction migrationDirection) error { if err != nil { return err } - db := SetupConnection("master", dataSource, ss.settings) + db, err2 := SetupConnection("master", dataSource, ss.settings, DBPingAttempts) + if err2 != nil { + return err2 + } driver, err = ms.WithInstance(db) defer db.Close() case model.DatabaseDriverPostgres: diff --git a/server/channels/store/sqlstore/store_test.go b/server/channels/store/sqlstore/store_test.go index c218fa205d..699ee53e98 100644 --- a/server/channels/store/sqlstore/store_test.go +++ b/server/channels/store/sqlstore/store_test.go @@ -761,13 +761,15 @@ func TestReplicaLagQuery(t *testing.T) { mockMetrics.On("RegisterDBCollector", mock.AnythingOfType("*sql.DB"), "master") store := &SqlStore{ - rrCounter: 0, - srCounter: 0, - settings: settings, - metrics: mockMetrics, + rrCounter: 0, + srCounter: 0, + settings: settings, + metrics: mockMetrics, + quitMonitor: make(chan struct{}), + wgMonitor: &sync.WaitGroup{}, } - store.initConnection() + require.NoError(t, store.initConnection()) store.stores.post = newSqlPostStore(store, mockMetrics) err = store.migrate(migrationsDirectionUp) require.NoError(t, err) @@ -839,9 +841,11 @@ func TestMySQLReadTimeout(t *testing.T) { settings.DataSource = &dataSource store := &SqlStore{ - settings: settings, + settings: settings, + quitMonitor: make(chan struct{}), + wgMonitor: &sync.WaitGroup{}, } - store.initConnection() + require.NoError(t, store.initConnection()) defer store.Close() _, err = store.GetMasterX().ExecNoTimeout(`SELECT SLEEP(3)`) diff --git a/server/channels/store/sqlstore/thread_store.go b/server/channels/store/sqlstore/thread_store.go index b731b0b71c..66ce1f42a1 100644 --- a/server/channels/store/sqlstore/thread_store.go +++ b/server/channels/store/sqlstore/thread_store.go @@ -688,6 +688,28 @@ func (s *SqlThreadStore) UpdateMembership(membership *model.ThreadMembership) (* return s.updateMembership(s.GetMasterX(), membership) } +func (s *SqlThreadStore) DeleteMembershipsForChannel(userID, channelID string) error { + subQuery := s.getSubQueryBuilder(). + Select("1"). + From("Threads"). + Where(sq.And{ + sq.Expr("Threads.PostId = ThreadMemberships.PostId"), + sq.Eq{"Threads.ChannelId": channelID}, + }) + + query := s.getQueryBuilder(). + Delete("ThreadMemberships"). + Where(sq.Eq{"UserId": userID}). + Where(sq.Expr("EXISTS (?)", subQuery)) + + _, err := s.GetMasterX().ExecBuilder(query) + if err != nil { + return errors.Wrapf(err, "failed to remove thread memberships with userid=%s channelid=%s", userID, channelID) + } + + return nil +} + func (s *SqlThreadStore) updateMembership(ex sqlxExecutor, membership *model.ThreadMembership) (*model.ThreadMembership, error) { query := s.getQueryBuilder(). Update("ThreadMemberships"). @@ -712,7 +734,14 @@ func (s *SqlThreadStore) GetMembershipsForUser(userId, teamId string) ([]*model. memberships := []*model.ThreadMembership{} query := s.getQueryBuilder(). - Select("ThreadMemberships.*"). + Select( + "ThreadMemberships.PostId", + "ThreadMemberships.UserId", + "ThreadMemberships.Following", + "ThreadMemberships.LastUpdated", + "ThreadMemberships.LastViewed", + "ThreadMemberships.UnreadMentions", + ). Join("Threads ON Threads.PostId = ThreadMemberships.PostId"). From("ThreadMemberships"). Where(sq.Or{sq.Eq{"Threads.ThreadTeamId": teamId}, sq.Eq{"Threads.ThreadTeamId": ""}}). @@ -732,7 +761,14 @@ func (s *SqlThreadStore) GetMembershipForUser(userId, postId string) (*model.Thr func (s *SqlThreadStore) getMembershipForUser(ex sqlxExecutor, userId, postId string) (*model.ThreadMembership, error) { var membership model.ThreadMembership query := s.getQueryBuilder(). - Select("*"). + Select( + "PostId", + "UserId", + "Following", + "LastUpdated", + "LastViewed", + "UnreadMentions", + ). From("ThreadMemberships"). Where(sq.And{ sq.Eq{"PostId": postId}, diff --git a/server/channels/store/store.go b/server/channels/store/store.go index 7da24fd24c..cd813239d4 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -72,10 +72,7 @@ type Store interface { // GetInternalMasterDB allows access to the raw master DB // handle for the multi-product architecture. GetInternalMasterDB() *sql.DB - // GetInternalReplicaDBs allows access to the raw replica DB - // handles for the multi-product architecture. GetInternalReplicaDB() *sql.DB - GetInternalReplicaDBs() []*sql.DB TotalMasterDbConnections() int TotalReadDbConnections() int TotalSearchDbConnections() int @@ -347,6 +344,7 @@ type ThreadStore interface { PermanentDeleteBatchThreadMembershipsForRetentionPolicies(now, globalPolicyEndTime, limit int64, cursor model.RetentionPolicyCursor) (int64, model.RetentionPolicyCursor, error) DeleteOrphanedRows(limit int) (deleted int64, err error) GetThreadUnreadReplyCount(threadMembership *model.ThreadMembership) (int64, error) + DeleteMembershipsForChannel(userID, channelID string) error // Insights - threads GetTopThreadsForTeamSince(teamID string, userID string, since int64, offset int, limit int) (*model.TopThreadList, error) diff --git a/server/channels/store/storetest/channel_store_categories.go b/server/channels/store/storetest/channel_store_categories.go index ecd49ef8c2..6ba934f45b 100644 --- a/server/channels/store/storetest/channel_store_categories.go +++ b/server/channels/store/storetest/channel_store_categories.go @@ -672,6 +672,38 @@ func testCreateSidebarCategory(t *testing.T, ss store.Store) { require.NoError(t, err) assert.Equal(t, []string{}, res2.Channels) }) + + t.Run("should store the correct sorting value", func(t *testing.T) { + userId := model.NewId() + + team := setupTeam(t, ss, userId) + + opts := &store.SidebarCategorySearchOpts{ + TeamID: team.Id, + ExcludeTeam: false, + } + res, nErr := ss.Channel().CreateInitialSidebarCategories(userId, opts) + require.NoError(t, nErr) + require.NotEmpty(t, res) + // Create the category + created, err := ss.Channel().CreateSidebarCategory(userId, team.Id, &model.SidebarCategoryWithChannels{ + SidebarCategory: model.SidebarCategory{ + DisplayName: model.NewId(), + Sorting: model.SidebarCategorySortManual, + }, + }) + require.NoError(t, err) + + // Confirm that sorting value is correct + res, err = ss.Channel().GetSidebarCategoriesForTeamForUser(userId, team.Id) + require.NoError(t, err) + require.Len(t, res.Categories, 4) + // first category will be favorites and second will be newly created + assert.Equal(t, model.SidebarCategoryCustom, res.Categories[1].Type) + assert.Equal(t, created.Id, res.Categories[1].Id) + assert.Equal(t, model.SidebarCategorySortManual, res.Categories[1].Sorting) + assert.Equal(t, model.SidebarCategorySortManual, created.Sorting) + }) } func testGetSidebarCategory(t *testing.T, ss store.Store, s SqlStore) { diff --git a/server/channels/store/storetest/mocks/Store.go b/server/channels/store/storetest/mocks/Store.go index bb06fb9005..bca15c95e0 100644 --- a/server/channels/store/storetest/mocks/Store.go +++ b/server/channels/store/storetest/mocks/Store.go @@ -346,22 +346,6 @@ func (_m *Store) GetInternalReplicaDB() *sql.DB { return r0 } -// GetInternalReplicaDBs provides a mock function with given fields: -func (_m *Store) GetInternalReplicaDBs() []*sql.DB { - ret := _m.Called() - - var r0 []*sql.DB - if rf, ok := ret.Get(0).(func() []*sql.DB); ok { - r0 = rf() - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]*sql.DB) - } - } - - return r0 -} - // Group provides a mock function with given fields: func (_m *Store) Group() store.GroupStore { ret := _m.Called() diff --git a/server/channels/store/storetest/mocks/ThreadStore.go b/server/channels/store/storetest/mocks/ThreadStore.go index 60b9211db2..661194a935 100644 --- a/server/channels/store/storetest/mocks/ThreadStore.go +++ b/server/channels/store/storetest/mocks/ThreadStore.go @@ -29,6 +29,20 @@ func (_m *ThreadStore) DeleteMembershipForUser(userId string, postID string) err return r0 } +// DeleteMembershipsForChannel provides a mock function with given fields: userID, channelID +func (_m *ThreadStore) DeleteMembershipsForChannel(userID string, channelID string) error { + ret := _m.Called(userID, channelID) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(userID, channelID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // DeleteOrphanedRows provides a mock function with given fields: limit func (_m *ThreadStore) DeleteOrphanedRows(limit int) (int64, error) { ret := _m.Called(limit) diff --git a/server/channels/store/storetest/settings.go b/server/channels/store/storetest/settings.go index a1253f28bb..0104b950bb 100644 --- a/server/channels/store/storetest/settings.go +++ b/server/channels/store/storetest/settings.go @@ -261,6 +261,7 @@ func MakeSqlSettings(driver string, withReplica bool) *model.SqlSettings { } log("Created temporary " + driver + " database " + dbName) + settings.ReplicaMonitorIntervalSeconds = model.NewInt(5) return settings } diff --git a/server/channels/store/storetest/thread_store.go b/server/channels/store/storetest/thread_store.go index 4cd64c8f1e..efbc74d3ac 100644 --- a/server/channels/store/storetest/thread_store.go +++ b/server/channels/store/storetest/thread_store.go @@ -29,6 +29,7 @@ func TestThreadStore(t *testing.T, ss store.Store, s SqlStore) { t.Run("MarkAllAsReadByChannels", func(t *testing.T) { testMarkAllAsReadByChannels(t, ss) }) t.Run("GetTopThreads", func(t *testing.T) { testGetTopThreads(t, ss) }) t.Run("MarkAllAsReadByTeam", func(t *testing.T) { testMarkAllAsReadByTeam(t, ss) }) + t.Run("DeleteMembershipsForChannel", func(t *testing.T) { testDeleteMembershipsForChannel(t, ss) }) } func testThreadStorePopulation(t *testing.T, ss store.Store) { @@ -1914,3 +1915,121 @@ func testMarkAllAsReadByTeam(t *testing.T, ss store.Store) { assertThreadReplyCount(t, userBID, team2.Id, 1, "expected 1 unread message in team2 for userB") }) } + +func testDeleteMembershipsForChannel(t *testing.T, ss store.Store) { + createThreadMembership := func(userID, postID string) (*model.ThreadMembership, func()) { + t.Helper() + opts := store.ThreadMembershipOpts{ + Following: true, + IncrementMentions: false, + UpdateFollowing: true, + UpdateViewedTimestamp: false, + UpdateParticipants: false, + } + mem, err := ss.Thread().MaintainMembership(userID, postID, opts) + require.NoError(t, err) + + return mem, func() { + err := ss.Thread().DeleteMembershipForUser(userID, postID) + require.NoError(t, err) + } + } + + postingUserID := model.NewId() + userAID := model.NewId() + userBID := model.NewId() + + team, err := ss.Team().Save(&model.Team{ + DisplayName: "DisplayName", + Name: "team" + model.NewId(), + Email: MakeEmail(), + Type: model.TeamOpen, + }) + require.NoError(t, err) + + channel1, err := ss.Channel().Save(&model.Channel{ + TeamId: team.Id, + DisplayName: "DisplayName", + Name: "channel1" + model.NewId(), + Type: model.ChannelTypeOpen, + }, -1) + require.NoError(t, err) + channel2, err := ss.Channel().Save(&model.Channel{ + TeamId: team.Id, + DisplayName: "DisplayName2", + Name: "channel2" + model.NewId(), + Type: model.ChannelTypeOpen, + }, -1) + require.NoError(t, err) + + rootPost1, err := ss.Post().Save(&model.Post{ + ChannelId: channel1.Id, + UserId: postingUserID, + Message: model.NewRandomString(10), + }) + require.NoError(t, err) + + _, err = ss.Post().Save(&model.Post{ + ChannelId: channel1.Id, + UserId: postingUserID, + Message: model.NewRandomString(10), + RootId: rootPost1.Id, + }) + require.NoError(t, err) + + rootPost2, err := ss.Post().Save(&model.Post{ + ChannelId: channel2.Id, + UserId: postingUserID, + Message: model.NewRandomString(10), + }) + require.NoError(t, err) + _, err = ss.Post().Save(&model.Post{ + ChannelId: channel2.Id, + UserId: postingUserID, + Message: model.NewRandomString(10), + RootId: rootPost2.Id, + }) + require.NoError(t, err) + + t.Run("should return memberships for user", func(t *testing.T) { + memA1, cleanupA1 := createThreadMembership(userAID, rootPost1.Id) + defer cleanupA1() + memA2, cleanupA2 := createThreadMembership(userAID, rootPost2.Id) + defer cleanupA2() + + membershipsA, err := ss.Thread().GetMembershipsForUser(userAID, team.Id) + require.NoError(t, err) + + require.Len(t, membershipsA, 2) + require.ElementsMatch(t, []*model.ThreadMembership{memA1, memA2}, membershipsA) + }) + + t.Run("should delete memberships for user for channel", func(t *testing.T) { + _, cleanupA1 := createThreadMembership(userAID, rootPost1.Id) + defer cleanupA1() + memA2, cleanupA2 := createThreadMembership(userAID, rootPost2.Id) + defer cleanupA2() + + ss.Thread().DeleteMembershipsForChannel(userAID, channel1.Id) + membershipsA, err := ss.Thread().GetMembershipsForUser(userAID, team.Id) + require.NoError(t, err) + + require.Len(t, membershipsA, 1) + require.ElementsMatch(t, []*model.ThreadMembership{memA2}, membershipsA) + }) + + t.Run("deleting memberships for channel for userA should not affect userB", func(t *testing.T) { + _, cleanupA1 := createThreadMembership(userAID, rootPost1.Id) + defer cleanupA1() + _, cleanupA2 := createThreadMembership(userAID, rootPost2.Id) + defer cleanupA2() + memB1, cleanupB2 := createThreadMembership(userBID, rootPost1.Id) + defer cleanupB2() + + membershipsB, err := ss.Thread().GetMembershipsForUser(userBID, team.Id) + require.NoError(t, err) + + require.Len(t, membershipsB, 1) + require.ElementsMatch(t, []*model.ThreadMembership{memB1}, membershipsB) + }) +} diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index b52293e013..3dc9a94c19 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -9112,6 +9112,22 @@ func (s *TimerLayerThreadStore) DeleteMembershipForUser(userId string, postID st return err } +func (s *TimerLayerThreadStore) DeleteMembershipsForChannel(userID string, channelID string) error { + start := time.Now() + + err := s.ThreadStore.DeleteMembershipsForChannel(userID, channelID) + + elapsed := float64(time.Since(start)) / float64(time.Second) + if s.Root.Metrics != nil { + success := "false" + if err == nil { + success = "true" + } + s.Root.Metrics.ObserveStoreMethodDuration("ThreadStore.DeleteMembershipsForChannel", success, elapsed) + } + return err +} + func (s *TimerLayerThreadStore) DeleteOrphanedRows(limit int) (int64, error) { start := time.Now() diff --git a/server/channels/testlib/helper.go b/server/channels/testlib/helper.go index f74a562568..f4e5d2bed6 100644 --- a/server/channels/testlib/helper.go +++ b/server/channels/testlib/helper.go @@ -58,6 +58,11 @@ func NewMainHelperWithOptions(options *HelperOptions) *MainHelper { os.Unsetenv("MM_SQLSETTINGS_DATASOURCE") } + // Unset environment variables commonly set for development that interfere with tests. + os.Unsetenv("MM_SERVICESETTINGS_SITEURL") + os.Unsetenv("MM_SERVICESETTINGS_LISTENADDRESS") + os.Unsetenv("MM_SERVICESETTINGS_ENABLEDEVELOPER") + var mainHelper MainHelper flag.Parse() @@ -331,7 +336,7 @@ func (h *MainHelper) SetReplicationLagForTesting(seconds int) error { func (h *MainHelper) execOnEachReplica(query string, args ...any) error { for _, replica := range h.SQLStore.ReplicaXs { - _, err := replica.Exec(query, args...) + _, err := replica.Load().Exec(query, args...) if err != nil { return err } diff --git a/server/channels/utils/license.go b/server/channels/utils/license.go index b937662f35..43f1f8a0ba 100644 --- a/server/channels/utils/license.go +++ b/server/channels/utils/license.go @@ -210,7 +210,6 @@ func GetSanitizedClientLicense(l map[string]string) map[string]string { delete(sanitizedLicense, "StartsAt") delete(sanitizedLicense, "ExpiresAt") delete(sanitizedLicense, "SkuName") - delete(sanitizedLicense, "SkuShortName") return sanitizedLicense } diff --git a/server/config/client.go b/server/config/client.go index 50bb37cccd..0de8694999 100644 --- a/server/config/client.go +++ b/server/config/client.go @@ -94,6 +94,8 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li props["CWSURL"] = *c.CloudSettings.CWSURL + props["DisableRefetchingOnBrowserFocus"] = strconv.FormatBool(*c.ExperimentalSettings.DisableRefetchingOnBrowserFocus) + // Set default values for all options that require a license. props["ExperimentalEnableAuthenticationTransfer"] = "true" props["LdapNicknameAttributeSet"] = "false" diff --git a/server/i18n/en.json b/server/i18n/en.json index 598462a448..40f626291f 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -1777,6 +1777,10 @@ "id": "api.error_get_first_admin_visit_marketplace_status", "translation": "Error trying to retrieve the first admin visit marketplace status from the store." }, + { + "id": "api.error_no_organization_name_provided_for_self_hosted_onboarding", + "translation": "Error no organization name provided for self hosted onboarding." + }, { "id": "api.error_set_first_admin_complete_setup", "translation": "Error trying to save first admin complete setup in the store." diff --git a/server/model/cloud.go b/server/model/cloud.go index a2176f8bd9..1efad1878c 100644 --- a/server/model/cloud.go +++ b/server/model/cloud.go @@ -179,6 +179,7 @@ type Subscription struct { DelinquentSince *int64 `json:"delinquent_since"` OriginallyLicensedSeats int `json:"originally_licensed_seats"` ComplianceBlocked string `json:"compliance_blocked"` + BillingType string `json:"billing_type"` } // Subscription History model represents true up event in a yearly subscription diff --git a/server/model/config.go b/server/model/config.go index 9d29ee48a4..2d00b9bdd4 100644 --- a/server/model/config.go +++ b/server/model/config.go @@ -974,6 +974,7 @@ type ExperimentalSettings struct { EnableRemoteClusterService *bool `access:"experimental_features"` EnableAppBar *bool `access:"experimental_features"` PatchPluginsReactDOM *bool `access:"experimental_features"` + DisableRefetchingOnBrowserFocus *bool `access:"experimental_features"` } func (s *ExperimentalSettings) SetDefaults() { @@ -1012,6 +1013,10 @@ func (s *ExperimentalSettings) SetDefaults() { if s.PatchPluginsReactDOM == nil { s.PatchPluginsReactDOM = NewBool(false) } + + if s.DisableRefetchingOnBrowserFocus == nil { + s.DisableRefetchingOnBrowserFocus = NewBool(false) + } } type AnalyticsSettings struct { @@ -1163,6 +1168,7 @@ type SqlSettings struct { DisableDatabaseSearch *bool `access:"environment_database,write_restrictable,cloud_restrictable"` MigrationsStatementTimeoutSeconds *int `access:"environment_database,write_restrictable,cloud_restrictable"` ReplicaLagSettings []*ReplicaLagSettings `access:"environment_database,write_restrictable,cloud_restrictable"` // telemetry: none + ReplicaMonitorIntervalSeconds *int `access:"environment_database,write_restrictable,cloud_restrictable"` } func (s *SqlSettings) SetDefaults(isUpdate bool) { @@ -1227,6 +1233,10 @@ func (s *SqlSettings) SetDefaults(isUpdate bool) { if s.ReplicaLagSettings == nil { s.ReplicaLagSettings = []*ReplicaLagSettings{} } + + if s.ReplicaMonitorIntervalSeconds == nil { + s.ReplicaMonitorIntervalSeconds = NewInt(5) + } } type LogSettings struct { diff --git a/server/model/hosted_customer.go b/server/model/hosted_customer.go index 608892e5e5..0b40f69c32 100644 --- a/server/model/hosted_customer.go +++ b/server/model/hosted_customer.go @@ -35,8 +35,9 @@ type SelfHostedCustomerForm struct { } type SelfHostedConfirmPaymentMethodRequest struct { - StripeSetupIntentID string `json:"stripe_setup_intent_id"` - Subscription CreateSubscriptionRequest `json:"subscription"` + StripeSetupIntentID string `json:"stripe_setup_intent_id"` + Subscription *CreateSubscriptionRequest `json:"subscription"` + ExpandRequest *SelfHostedExpansionRequest `json:"expand_request"` } // SelfHostedSignupPaymentResponse contains feels needed for self hosted signup to confirm payment and receive license. @@ -65,3 +66,8 @@ type SelfHostedBillingAccessRequest struct { type SelfHostedBillingAccessResponse struct { Token string `json:"token"` } + +type SelfHostedExpansionRequest struct { + Seats int `json:"seats"` + LicenseId string `json:"license_id"` +} diff --git a/server/model/onboarding.go b/server/model/onboarding.go index 797bea7c1d..0fe5e91ffa 100644 --- a/server/model/onboarding.go +++ b/server/model/onboarding.go @@ -10,6 +10,7 @@ import ( // CompleteOnboardingRequest describes parameters of the requested plugin. type CompleteOnboardingRequest struct { + Organization string `json:"organization"` // Organization is the name of the organization InstallPlugins []string `json:"install_plugins"` // InstallPlugins is a list of plugins to be installed } diff --git a/server/model/permission.go b/server/model/permission.go index a44a566964..231154e2d4 100644 --- a/server/model/permission.go +++ b/server/model/permission.go @@ -21,6 +21,9 @@ type Permission struct { var PermissionInviteUser *Permission var PermissionAddUserToTeam *Permission + +// Deprecated: PermissionCreatePost should be used to determine if a slash command can be executed. +// TODO: Remove in 8.0: https://mattermost.atlassian.net/browse/MM-51274 var PermissionUseSlashCommands *Permission var PermissionManageSlashCommands *Permission var PermissionManageOthersSlashCommands *Permission diff --git a/server/model/system.go b/server/model/system.go index fbc2aaa684..24b4fce9c9 100644 --- a/server/model/system.go +++ b/server/model/system.go @@ -16,6 +16,7 @@ const ( SystemAsymmetricSigningKeyKey = "AsymmetricSigningKey" SystemPostActionCookieSecretKey = "PostActionCookieSecret" SystemInstallationDateKey = "InstallationDate" + SystemOrganizationName = "OrganizationName" SystemFirstServerRunTimestampKey = "FirstServerRunTimestamp" SystemClusterEncryptionKey = "ClusterEncryptionKey" SystemUpgradedFromTeId = "UpgradedFromTE" diff --git a/server/model/version.go b/server/model/version.go index 1683e810d5..51b6257e7f 100644 --- a/server/model/version.go +++ b/server/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{ + "7.11.0", "7.10.0", "7.9.0", "7.8.0", diff --git a/server/platform/services/telemetry/telemetry.go b/server/platform/services/telemetry/telemetry.go index 4e642f3300..31ea427505 100644 --- a/server/platform/services/telemetry/telemetry.go +++ b/server/platform/services/telemetry/telemetry.go @@ -521,6 +521,7 @@ func (ts *TelemetryService) trackConfig() { "query_timeout": *cfg.SqlSettings.QueryTimeout, "disable_database_search": *cfg.SqlSettings.DisableDatabaseSearch, "migrations_statement_timeout_seconds": *cfg.SqlSettings.MigrationsStatementTimeoutSeconds, + "replica_monitor_interval_seconds": *cfg.SqlSettings.ReplicaMonitorIntervalSeconds, }) ts.SendTelemetry(TrackConfigLog, map[string]any{ @@ -749,15 +750,16 @@ func (ts *TelemetryService) trackConfig() { }) ts.SendTelemetry(TrackConfigExperimental, map[string]any{ - "client_side_cert_enable": *cfg.ExperimentalSettings.ClientSideCertEnable, - "isdefault_client_side_cert_check": isDefault(*cfg.ExperimentalSettings.ClientSideCertCheck, model.ClientSideCertCheckPrimaryAuth), - "link_metadata_timeout_milliseconds": *cfg.ExperimentalSettings.LinkMetadataTimeoutMilliseconds, - "restrict_system_admin": *cfg.ExperimentalSettings.RestrictSystemAdmin, - "use_new_saml_library": *cfg.ExperimentalSettings.UseNewSAMLLibrary, - "enable_shared_channels": *cfg.ExperimentalSettings.EnableSharedChannels, - "enable_remote_cluster_service": *cfg.ExperimentalSettings.EnableRemoteClusterService && cfg.FeatureFlags.EnableRemoteClusterService, - "enable_app_bar": *cfg.ExperimentalSettings.EnableAppBar, - "patch_plugins_react_dom": *cfg.ExperimentalSettings.PatchPluginsReactDOM, + "client_side_cert_enable": *cfg.ExperimentalSettings.ClientSideCertEnable, + "isdefault_client_side_cert_check": isDefault(*cfg.ExperimentalSettings.ClientSideCertCheck, model.ClientSideCertCheckPrimaryAuth), + "link_metadata_timeout_milliseconds": *cfg.ExperimentalSettings.LinkMetadataTimeoutMilliseconds, + "restrict_system_admin": *cfg.ExperimentalSettings.RestrictSystemAdmin, + "use_new_saml_library": *cfg.ExperimentalSettings.UseNewSAMLLibrary, + "enable_shared_channels": *cfg.ExperimentalSettings.EnableSharedChannels, + "enable_remote_cluster_service": *cfg.ExperimentalSettings.EnableRemoteClusterService && cfg.FeatureFlags.EnableRemoteClusterService, + "enable_app_bar": *cfg.ExperimentalSettings.EnableAppBar, + "patch_plugins_react_dom": *cfg.ExperimentalSettings.PatchPluginsReactDOM, + "disable_refetching_on_browser_focus": *cfg.ExperimentalSettings.DisableRefetchingOnBrowserFocus, }) ts.SendTelemetry(TrackConfigAnalytics, map[string]any{ diff --git a/webapp/channels/src/actions/global_actions.tsx b/webapp/channels/src/actions/global_actions.tsx index cb22542fa4..6ac09280ab 100644 --- a/webapp/channels/src/actions/global_actions.tsx +++ b/webapp/channels/src/actions/global_actions.tsx @@ -14,7 +14,7 @@ import {Preferences} from 'mattermost-redux/constants'; import {getConfig, isPerformanceDebuggingEnabled} from 'mattermost-redux/selectors/entities/general'; import {getCurrentTeamId, getMyTeams, getTeam, getMyTeamMember, getTeamMemberships} from 'mattermost-redux/selectors/entities/teams'; import {getBool, isCollapsedThreadsEnabled, isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences'; -import {getCurrentUser, getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; +import {getCurrentUser, getCurrentUserId, isFirstAdmin} from 'mattermost-redux/selectors/entities/users'; import {getCurrentChannelStats, getCurrentChannelId, getMyChannelMember, getRedirectChannelNameForTeam, getChannelsNameMapInTeam, getAllDirectChannels, getChannelMessageCount} from 'mattermost-redux/selectors/entities/channels'; import {appsEnabled} from 'mattermost-redux/selectors/entities/apps'; import {ChannelTypes} from 'mattermost-redux/action_types'; @@ -367,11 +367,19 @@ export async function redirectUserToDefaultTeam() { return; } + // if the user is the first admin + const isUserFirstAdmin = isFirstAdmin(state); + const locale = getCurrentLocale(state); const teamId = LocalStorageStore.getPreviousTeamId(user.id); let myTeams = getMyTeams(state); if (myTeams.length === 0) { + if (isUserFirstAdmin) { + getHistory().push('/preparing-workspace'); + return; + } + getHistory().push('/select_team'); return; } diff --git a/webapp/channels/src/components/admin_console/admin_definition.jsx b/webapp/channels/src/components/admin_console/admin_definition.jsx index 1801037c3f..abab875ec2 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.jsx +++ b/webapp/channels/src/components/admin_console/admin_definition.jsx @@ -6887,6 +6887,15 @@ const AdminDefinition = { isHidden: it.licensedForFeature('Cloud'), isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)), }, + { + type: Constants.SettingsTypes.TYPE_BOOL, + key: 'ExperimentalSettings.DisableRefetchingOnBrowserFocus', + label: t('admin.experimental.disableRefetchingOnBrowserFocus.title'), + label_default: 'Disable data refetching on browser refocus:', + help_text: t('admin.experimental.disableRefetchingOnBrowserFocus.desc'), + help_text_default: 'When true, Mattermost will not refetch channels and channel members when the browser regains focus. This may result in improved performance for users with many channels and channel members.', + isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)), + }, ], }, }, diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_yearly_nudge_banner.test.tsx b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_yearly_nudge_banner.test.tsx index e9061333a5..1fd7456656 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_yearly_nudge_banner.test.tsx +++ b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_yearly_nudge_banner.test.tsx @@ -197,6 +197,58 @@ describe('ToYearlyNudgeBannerDismissable', () => { expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow(); }); + + test('should NOT show when subscription has billing type of internal', () => { + const state = JSON.parse(JSON.stringify(initialState)); + state.entities.users.profiles = { + current_user_id: {roles: 'system_admin'}, + }; + state.entities.cloud = { + subscription: { + product_id: 'prod_professional', + is_free_trial: 'false', + trial_end_at: 1, + billing_type: 'internal', + }, + products: { + prod_professional: { + id: 'prod_professional', + sku: CloudProducts.PROFESSIONAL, + recurring_interval: RecurringIntervals.MONTH, + }, + }, + }; + + renderWithIntlAndStore(, state); + + expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow(); + }); + + test('should NOT show when subscription has billing type of licensed', () => { + const state = JSON.parse(JSON.stringify(initialState)); + state.entities.users.profiles = { + current_user_id: {roles: 'system_admin'}, + }; + state.entities.cloud = { + subscription: { + product_id: 'prod_professional', + is_free_trial: 'false', + trial_end_at: 1, + billing_type: 'licensed', + }, + products: { + prod_professional: { + id: 'prod_professional', + sku: CloudProducts.PROFESSIONAL, + recurring_interval: RecurringIntervals.MONTH, + }, + }, + }; + + renderWithIntlAndStore(, state); + + expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow(); + }); }); describe('ToYearlyNudgeBanner', () => { @@ -241,6 +293,51 @@ describe('ToYearlyNudgeBanner', () => { renderWithIntlAndStore(, state); + expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-alert-banner')).toThrow(); + }); + test('should NOT show when subscription has billing type of internal', () => { + const state = JSON.parse(JSON.stringify(initialState)); + state.entities.cloud = { + subscription: { + product_id: 'prod_professional', + is_free_trial: 'false', + trial_end_at: 1, + billing_type: 'internal', + }, + products: { + prod_professional: { + id: 'prod_professional', + sku: CloudProducts.PROFESSIONAL, + recurring_interval: RecurringIntervals.MONTH, + }, + }, + }; + + renderWithIntlAndStore(, state); + + expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-alert-banner')).toThrow(); + }); + + test('should NOT show when subscription has billing type of licensed', () => { + const state = JSON.parse(JSON.stringify(initialState)); + state.entities.cloud = { + subscription: { + product_id: 'prod_professional', + is_free_trial: 'false', + trial_end_at: 1, + billing_type: 'licensed', + }, + products: { + prod_professional: { + id: 'prod_professional', + sku: CloudProducts.PROFESSIONAL, + recurring_interval: RecurringIntervals.MONTH, + }, + }, + }; + + renderWithIntlAndStore(, state); + expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-alert-banner')).toThrow(); }); }); diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_yearly_nudge_banner.tsx b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_yearly_nudge_banner.tsx index f2eac86e69..e5640663b0 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_yearly_nudge_banner.tsx +++ b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_yearly_nudge_banner.tsx @@ -11,12 +11,12 @@ import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurch import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; import AnnouncementBar from 'components/announcement_bar/default_announcement_bar'; -import {getSubscriptionProduct as selectSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud'; +import {getSubscriptionProduct as selectSubscriptionProduct, getCloudSubscription as selectCloudSubscription} from 'mattermost-redux/selectors/entities/cloud'; import {getCurrentUser, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; import {savePreferences} from 'mattermost-redux/actions/preferences'; import {get as getPreference} from 'mattermost-redux/selectors/entities/preferences'; -import {AnnouncementBarTypes, CloudBanners, CloudProducts, Preferences, RecurringIntervals} from 'utils/constants'; +import {AnnouncementBarTypes, CloudBanners, CloudProducts, Preferences, RecurringIntervals, CloudBillingTypes} from 'utils/constants'; import {t} from 'utils/i18n'; import {GlobalState} from '@mattermost/types/store'; @@ -53,6 +53,7 @@ const ToYearlyNudgeBannerDismissable = () => { const show = snoozeInfo.show; const currentUser = useSelector(getCurrentUser); + const subscription = useSelector(selectCloudSubscription); const isAdmin = useSelector(isCurrentUserSystemAdmin); const product = useSelector(selectSubscriptionProduct); const currentProductProfessional = product?.sku === CloudProducts.PROFESSIONAL; @@ -139,6 +140,10 @@ const ToYearlyNudgeBannerDismissable = () => { return null; } + if (subscription?.billing_type === CloudBillingTypes.INTERNAL || subscription?.billing_type === CloudBillingTypes.LICENSED) { + return null; + } + const message = ( { showCloseButton={daysToProMonthlyEnd > 10} onButtonClick={() => openPurchaseModal({trackingLocation: 'to_yearly_nudge_annoucement_bar'})} modalButtonText={t('cloud_billing.nudge_to_yearly.learn_more')} - modalButtonDefaultText='Learn more' + modalButtonDefaultText='Update billing' message={message} showLinkAsButton={true} handleClose={showBanner} @@ -173,6 +178,7 @@ const ToYearlyNudgeBanner = () => { const [openSalesLink] = useOpenSalesLink(); const openPurchaseModal = useOpenCloudPurchaseModal({}); + const subscription = useSelector(selectCloudSubscription); const product = useSelector(selectSubscriptionProduct); const currentProductProfessional = product?.sku === CloudProducts.PROFESSIONAL; const currentProductIsMonthly = product?.recurring_interval === RecurringIntervals.MONTH; @@ -182,6 +188,10 @@ const ToYearlyNudgeBanner = () => { return null; } + if (subscription?.billing_type === CloudBillingTypes.INTERNAL || subscription?.billing_type === CloudBillingTypes.LICENSED) { + return null; + } + const now = moment(Date.now()); const proMonthlyEndDate = moment(cloudProMonthlyCloseMoment, 'YYYYMMDD'); const daysToProMonthlyEnd = proMonthlyEndDate.diff(now, 'days'); diff --git a/webapp/channels/src/components/admin_console/generated_setting.tsx b/webapp/channels/src/components/admin_console/generated_setting.tsx index 8f00f5c862..4665443779 100644 --- a/webapp/channels/src/components/admin_console/generated_setting.tsx +++ b/webapp/channels/src/components/admin_console/generated_setting.tsx @@ -38,7 +38,11 @@ export default class GeneratedSetting extends React.PureComponent { private regenerate = (e: React.MouseEvent) => { e.preventDefault(); - this.props.onChange(this.props.id, crypto.randomBytes(256).toString('base64').substring(0, 32)); + // Pure base64 implementation can contain characters that are not URL safe without additional + // encoding. Adopt a URL/Filename safer alphabet as noted in https://datatracker.ietf.org/doc/html/rfc4648#section-5 + // where: 62 - (minus) , 63 _ (underscore) + const value = crypto.randomBytes(256).toString('base64').substring(0, 32); + this.props.onChange(this.props.id, value.replaceAll('+', '-').replaceAll('/', '_')); }; public render() { diff --git a/webapp/channels/src/components/apps_form/apps_form_field/apps_form_select_field.tsx b/webapp/channels/src/components/apps_form/apps_form_field/apps_form_select_field.tsx index 59fbe98db7..7ff560353d 100644 --- a/webapp/channels/src/components/apps_form/apps_form_field/apps_form_select_field.tsx +++ b/webapp/channels/src/components/apps_form/apps_form_field/apps_form_select_field.tsx @@ -90,7 +90,7 @@ export default class AppsFormSelectField extends React.PureComponent => { const usersSearchResults: UserAutocomplete = await this.props.actions.autocompleteUsers(userInput.toLowerCase()); - return usersSearchResults.users.map((user) => { + return usersSearchResults.users.filter((user) => !user.is_bot).map((user) => { const label = this.props.teammateNameDisplay ? displayUsername(user, this.props.teammateNameDisplay) : user.username; return {...user, label, value: user.id, icon_data: imageURLForUser(user.id)}; 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 e7f6c4ffec..38fb416e91 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 @@ -65,7 +65,6 @@ exports[`component/create_user_groups_modal should match snapshot with back butt
- +
{ const token = params.get('token') ?? ''; const loggedIn = Boolean(useSelector(getCurrentUserId)); - const useCaseOnboarding = useSelector(getUseCaseOnboarding); const [verifyStatus, setVerifyStatus] = useState(VerifyStatus.PENDING); const [serverError, setServerError] = useState(''); @@ -52,16 +49,11 @@ const DoVerifyEmail = () => { const handleRedirect = () => { if (loggedIn) { - if (useCaseOnboarding) { - // need info about whether admin or not, - // and whether admin has already completed - // first time onboarding. Instead of fetching and orchestrating that here, - // let the default root component handle it. - history.push('/'); - return; - } - - redirectUserToDefaultTeam(); + // need info about whether admin or not, + // and whether admin has already completed + // first time onboarding. Instead of fetching and orchestrating that here, + // let the default root component handle it. + history.push('/'); return; } diff --git a/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/index.ts b/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/index.ts index a59ff532cc..5a2ac01c35 100644 --- a/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/index.ts +++ b/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/index.ts @@ -8,7 +8,6 @@ import {withRouter} from 'react-router-dom'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {GenericAction} from 'mattermost-redux/types/actions'; import {getCurrentRelativeTeamUrl} from 'mattermost-redux/selectors/entities/teams'; -import {getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences'; import {isFirstAdmin} from 'mattermost-redux/selectors/entities/users'; import {getUserGuideDropdownPluginMenuItems} from 'selectors/plugins'; @@ -32,7 +31,6 @@ function mapStateToProps(state: GlobalState) { teamUrl: getCurrentRelativeTeamUrl(state), pluginMenuItems: getUserGuideDropdownPluginMenuItems(state), isFirstAdmin: isFirstAdmin(state), - useCaseOnboarding: getUseCaseOnboarding(state), }; } diff --git a/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/user_guide_dropdown.test.tsx b/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/user_guide_dropdown.test.tsx index effe92c1ad..aa1ac2e833 100644 --- a/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/user_guide_dropdown.test.tsx +++ b/webapp/channels/src/components/global_header/center_controls/user_guide_dropdown/user_guide_dropdown.test.tsx @@ -34,7 +34,6 @@ describe('components/channel_header/components/UserGuideDropdown', () => { }, pluginMenuItems: [], isFirstAdmin: false, - useCaseOnboarding: false, }; test('should match snapshot', () => { diff --git a/webapp/channels/src/components/login/login.test.tsx b/webapp/channels/src/components/login/login.test.tsx index b1116f4d2c..512c98856f 100644 --- a/webapp/channels/src/components/login/login.test.tsx +++ b/webapp/channels/src/components/login/login.test.tsx @@ -288,4 +288,18 @@ describe('components/login/Login', () => { expect(externalLoginButton.props().label).toEqual('OpenID 2'); expect(externalLoginButton.props().style).toEqual({color: '#00ff00', borderColor: '#00ff00'}); }); + + it('should redirect on login', () => { + mockState.entities.users.currentUserId = 'user1'; + LocalStorageStore.setWasLoggedIn(true); + mockConfig.EnableSignInWithEmail = 'true'; + const redirectPath = '/boards/team/teamID/boardID'; + mockLocation.search = '?redirect_to=' + redirectPath; + mount( + + + , + ); + expect(mockHistoryPush).toHaveBeenCalledWith(redirectPath); + }); }); diff --git a/webapp/channels/src/components/login/login.tsx b/webapp/channels/src/components/login/login.tsx index bed62d7eed..edde751e63 100644 --- a/webapp/channels/src/components/login/login.tsx +++ b/webapp/channels/src/components/login/login.tsx @@ -13,7 +13,7 @@ import {UserProfile} from '@mattermost/types/users'; import {Client4} from 'mattermost-redux/client'; import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; -import {getUseCaseOnboarding, isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences'; +import {isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences'; import {getTeamByName, getMyTeamMember} from 'mattermost-redux/selectors/entities/teams'; import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; import {isSystemAdmin} from 'mattermost-redux/utils/user_utils'; @@ -104,7 +104,6 @@ const Login = ({onCustomizeHeader}: LoginProps) => { const currentUser = useSelector(getCurrentUser); const experimentalPrimaryTeam = useSelector((state: GlobalState) => (ExperimentalPrimaryTeam ? getTeamByName(state, ExperimentalPrimaryTeam) : undefined)); const experimentalPrimaryTeamMember = useSelector((state: GlobalState) => getMyTeamMember(state, experimentalPrimaryTeam?.id ?? '')); - const useCaseOnboarding = useSelector(getUseCaseOnboarding); const isCloud = useSelector(isCurrentLicenseCloud); const graphQLEnabled = useSelector(isGraphQLEnabled); @@ -142,6 +141,9 @@ const Login = ({onCustomizeHeader}: LoginProps) => { const enableExternalSignup = enableSignUpWithGitLab || enableSignUpWithOffice365 || enableSignUpWithGoogle || enableSignUpWithOpenId || enableSignUpWithSaml; const showSignup = enableOpenServer && (enableExternalSignup || enableSignUpWithEmail || enableLdap); + const query = new URLSearchParams(search); + const redirectTo = query.get('redirect_to'); + const getExternalLoginOptions = () => { const externalLoginOptions: ExternalLoginButtonType[] = []; @@ -373,6 +375,10 @@ const Login = ({onCustomizeHeader}: LoginProps) => { useEffect(() => { if (currentUser) { + if (redirectTo && redirectTo.match(/^\/([^/]|$)/)) { + history.push(redirectTo); + return; + } redirectUserToDefaultTeam(); return; } @@ -616,9 +622,6 @@ const Login = ({onCustomizeHeader}: LoginProps) => { dispatch(setNeedsLoggedInLimitReachedCheck(true)); } - const query = new URLSearchParams(search); - const redirectTo = query.get('redirect_to'); - setCSRFFromCookie(); // Record a successful login to local storage. If an unintentional logout occurs, e.g. @@ -631,14 +634,12 @@ const Login = ({onCustomizeHeader}: LoginProps) => { } else if (experimentalPrimaryTeamMember.team_id) { // Only set experimental team if user is on that team history.push(`/${ExperimentalPrimaryTeam}`); - } else if (useCaseOnboarding) { + } else { // need info about whether admin or not, // and whether admin has already completed // first time onboarding. Instead of fetching and orchestrating that here, // let the default root component handle it. history.push('/'); - } else { - redirectUserToDefaultTeam(); } }; diff --git a/webapp/channels/src/components/preparing_workspace/__snapshots__/invite_members.test.tsx.snap b/webapp/channels/src/components/preparing_workspace/__snapshots__/invite_members.test.tsx.snap new file mode 100644 index 0000000000..4aa2442f0a --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/__snapshots__/invite_members.test.tsx.snap @@ -0,0 +1,80 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InviteMembers component should match snapshot 1`] = ` +
+
+
+
+
+
+ Previous step +
+

+ + Invite your team members + +

+

+ + Collaboration is tough by yourself. Invite a few team members using the invitation link below. + +

+
+ +
+
+ +
+
+
+
+
+
+`; diff --git a/webapp/channels/src/components/preparing_workspace/__snapshots__/invite_members_link.test.tsx.snap b/webapp/channels/src/components/preparing_workspace/__snapshots__/invite_members_link.test.tsx.snap new file mode 100644 index 0000000000..0610646391 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/__snapshots__/invite_members_link.test.tsx.snap @@ -0,0 +1,29 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/preparing-workspace/invite_members_link should match snapshot 1`] = ` +
+ +
+`; diff --git a/webapp/channels/src/components/preparing_workspace/__snapshots__/organization_status.test.tsx.snap b/webapp/channels/src/components/preparing_workspace/__snapshots__/organization_status.test.tsx.snap new file mode 100644 index 0000000000..cec545b0bd --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/__snapshots__/organization_status.test.tsx.snap @@ -0,0 +1,7 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`components/preparing-workspace/organization_status should match snapshot 1`] = ` +
+`; diff --git a/webapp/channels/src/components/preparing_workspace/index.tsx b/webapp/channels/src/components/preparing_workspace/index.tsx index a3ab4aa606..c454fd28ac 100644 --- a/webapp/channels/src/components/preparing_workspace/index.tsx +++ b/webapp/channels/src/components/preparing_workspace/index.tsx @@ -5,7 +5,7 @@ import {connect} from 'react-redux'; import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux'; import {Action} from 'mattermost-redux/types/actions'; -import {checkIfTeamExists, createTeam} from 'mattermost-redux/actions/teams'; +import {checkIfTeamExists, createTeam, updateTeam} from 'mattermost-redux/actions/teams'; import {getProfiles} from 'mattermost-redux/actions/users'; import PreparingWorkspace, {Actions} from './preparing_workspace'; @@ -13,6 +13,7 @@ import PreparingWorkspace, {Actions} from './preparing_workspace'; function mapDispatchToProps(dispatch: Dispatch) { return { actions: bindActionCreators, Actions>({ + updateTeam, createTeam, getProfiles, checkIfTeamExists, diff --git a/webapp/channels/src/components/preparing_workspace/invite_members.scss b/webapp/channels/src/components/preparing_workspace/invite_members.scss new file mode 100644 index 0000000000..dc914d42b1 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members.scss @@ -0,0 +1,51 @@ +@import 'utils/mixins'; + +.InviteMembers-body { + display: flex; + // page width - channels preview width - progress dots width - people overlap width + max-width: calc(100vw - 600px - 120px - 30px); + + .UsersEmailsInput { + max-width: 420px; + } +} + +.InviteMembers { + &__submit { + display: flex; + align-items: center; + justify-content: flex-start; + } +} + +@include simple-in-and-out-before("InviteMembers"); + +.ChannelsPreview--enter-from-after { + &-enter { + transform: translateX(-100vw); + } + + &-enter-active { + transform: translateX(0); + transition: transform 300ms ease-in-out; + } + + &-enter-done { + transform: translateX(0); + } +} + +.ChannelsPreview--exit-to-after { + &-exit { + transform: translateX(0); + } + + &-exit-active { + transform: translateX(-100vw); + transition: transform 300ms ease-in-out; + } + + &-exit-done { + transform: translateX(-100vw); + } +} diff --git a/webapp/channels/src/components/preparing_workspace/invite_members.test.tsx b/webapp/channels/src/components/preparing_workspace/invite_members.test.tsx new file mode 100644 index 0000000000..54fe45f374 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members.test.tsx @@ -0,0 +1,71 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {ComponentProps} from 'react'; +import {render, screen, fireEvent} from '@testing-library/react'; +import {withIntl} from 'tests/helpers/intl-test-helper'; + +import InviteMembers from './invite_members'; + +describe('InviteMembers component', () => { + let defaultProps: ComponentProps; + + beforeEach(() => { + defaultProps = { + disableEdits: false, + browserSiteUrl: 'https://my-org.mattermost.com', + formUrl: 'https://my-org.mattermost.com/signup', + teamInviteId: '1234', + className: 'test-class', + configSiteUrl: 'https://my-org.mattermost.com/config', + onPageView: jest.fn(), + previous:
{'Previous step'}
, + next: jest.fn(), + show: true, + transitionDirection: 'forward', + }; + }); + + it('should match snapshot', () => { + const component = withIntl(); + const {container} = render(component); + expect(container).toMatchSnapshot(); + }); + + it('renders invite URL', () => { + const component = withIntl(); + render(component); + const inviteLink = screen.getByTestId('shareLinkInput'); + expect(inviteLink).toHaveAttribute( + 'value', + 'https://my-org.mattermost.com/config/signup_user_complete/?id=1234', + ); + }); + + it('renders submit button with correct text', () => { + const component = withIntl(); + render(component); + const button = screen.getByRole('button', {name: 'Finish setup'}); + expect(button).toBeInTheDocument(); + }); + + it('button is disabled when disableEdits is true', () => { + const component = withIntl( + , + ); + render(component); + const button = screen.getByRole('button', {name: 'Finish setup'}); + expect(button).toBeDisabled(); + }); + + it('invokes next prop on button click', () => { + const component = withIntl(); + render(component); + const button = screen.getByRole('button', {name: 'Finish setup'}); + fireEvent.click(button); + expect(defaultProps.next).toHaveBeenCalled(); + }); +}); diff --git a/webapp/channels/src/components/preparing_workspace/invite_members.tsx b/webapp/channels/src/components/preparing_workspace/invite_members.tsx new file mode 100644 index 0000000000..a018c2a446 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members.tsx @@ -0,0 +1,114 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo, useEffect} from 'react'; +import {CSSTransition} from 'react-transition-group'; +import {FormattedMessage} from 'react-intl'; + +import {Animations, mapAnimationReasonToClass, Form, PreparingWorkspacePageProps} from './steps'; + +import Title from './title'; +import Description from './description'; +import PageBody from './page_body'; +import SingleColumnLayout from './single_column_layout'; + +import InviteMembersLink from './invite_members_link'; +import PageLine from './page_line'; +import './invite_members.scss'; + +type Props = PreparingWorkspacePageProps & { + disableEdits: boolean; + className?: string; + teamInviteId?: string; + formUrl: Form['url']; + configSiteUrl?: string; + browserSiteUrl: string; +} + +const InviteMembers = (props: Props) => { + let className = 'InviteMembers-body'; + if (props.className) { + className += ' ' + props.className; + } + + useEffect(props.onPageView, []); + + const inviteURL = useMemo(() => { + let urlBase = ''; + if (props.configSiteUrl && !props.configSiteUrl.includes('localhost')) { + urlBase = props.configSiteUrl; + } else if (props.formUrl && !props.formUrl.includes('localhost')) { + urlBase = props.formUrl; + } else { + urlBase = props.browserSiteUrl; + } + return `${urlBase}/signup_user_complete/?id=${props.teamInviteId}`; + }, [props.teamInviteId, props.configSiteUrl, props.browserSiteUrl, props.formUrl]); + + const description = ( + + ); + + const inviteInteraction = ; + + return ( + +
+ + + {props.previous} + + <FormattedMessage + id={'onboarding_wizard.invite_members.title'} + defaultMessage='Invite your team members' + /> + + + {description} + + + {inviteInteraction} + +
+ +
+ +
+
+
+ ); +}; + +export default InviteMembers; diff --git a/webapp/channels/src/components/preparing_workspace/invite_members_illustration.tsx b/webapp/channels/src/components/preparing_workspace/invite_members_illustration.tsx new file mode 100644 index 0000000000..26b28e9b6f --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members_illustration.tsx @@ -0,0 +1,838 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {SVGProps} from 'react'; + +const InviteMembersIllustration = (props: SVGProps) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export default InviteMembersIllustration; diff --git a/webapp/channels/src/components/preparing_workspace/invite_members_link.scss b/webapp/channels/src/components/preparing_workspace/invite_members_link.scss new file mode 100644 index 0000000000..09b229f264 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members_link.scss @@ -0,0 +1,51 @@ +.InviteMembersLink { + display: flex; + + &__input { + height: 48px; + flex-grow: 1; + padding: 12px 14px; + border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.2); + border-right: 0; + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.2); + border-left: 1px solid rgba(var(--center-channel-color-rgb), 0.2); + background: rgba(var(--center-channel-color-rgb), 0.04); + border-radius: 4px 0 0 4px; + color: rgba(var(--center-channel-color-rgb), 0.56); + font-size: 16px; + } + + &__button { + display: flex; + width: 180px; + max-width: 382px; + height: 48px; + flex-grow: 0; + align-items: center; + justify-content: center; + border: 1px solid var(--button-bg); + background: var(--center-channel-bg); + border-radius: 0 4px 4px 0; + color: var(--button-bg); + font-size: 16px; + font-weight: 600; + + &:hover { + background: rgba(var(--button-bg-rgb), 0.08); + } + + &:active { + background: rgba(var(--button-bg-rgb), 0.08); + } + + span { + display: inline-block; + height: 24px; + margin-right: 9px; + } + + svg { + fill: var(--button-bg); + } + } +} diff --git a/webapp/channels/src/components/preparing_workspace/invite_members_link.test.tsx b/webapp/channels/src/components/preparing_workspace/invite_members_link.test.tsx new file mode 100644 index 0000000000..d74b81d493 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members_link.test.tsx @@ -0,0 +1,61 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {render, screen, fireEvent} from '@testing-library/react'; +import {trackEvent} from 'actions/telemetry_actions'; +import InviteMembersLink from './invite_members_link'; +import {withIntl} from 'tests/helpers/intl-test-helper'; + +jest.mock('actions/telemetry_actions', () => ({ + trackEvent: jest.fn(), +})); + +describe('components/preparing-workspace/invite_members_link', () => { + const inviteURL = 'https://invite-url.mattermost.com'; + + it('should match snapshot', () => { + const component = withIntl(); + + const {container} = render(component); + expect(container).toMatchSnapshot(); + }); + + it('renders an input field with the invite URL', () => { + const component = withIntl(); + render(component); + const input = screen.getByDisplayValue(inviteURL); + expect(input).toBeInTheDocument(); + }); + + it('renders a button to copy the invite URL', () => { + const component = withIntl(); + render(component); + const button = screen.getByRole('button', {name: /copy link/i}); + expect(button).toBeInTheDocument(); + }); + + it('calls the trackEvent function when the copy button is clicked', () => { + const component = withIntl(); + render(component); + const button = screen.getByRole('button', {name: /copy link/i}); + fireEvent.click(button); + expect(trackEvent).toHaveBeenCalledWith( + 'first_admin_setup', + 'admin_setup_click_copy_invite_link', + ); + }); + + it('changes the button text to "Link Copied" when the URL is copied', () => { + const component = withIntl(); + render(component); + const button = screen.getByRole('button', {name: /copy link/i}); + const originalText = 'Copy Link'; + const linkCopiedText = 'Link Copied'; + expect(button).toHaveTextContent(originalText); + + fireEvent.click(button); + + expect(button).toHaveTextContent(linkCopiedText); + }); +}); diff --git a/webapp/channels/src/components/preparing_workspace/invite_members_link.tsx b/webapp/channels/src/components/preparing_workspace/invite_members_link.tsx new file mode 100644 index 0000000000..f6491809ca --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/invite_members_link.tsx @@ -0,0 +1,64 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import useCopyText from 'components/common/hooks/useCopyText'; +import {trackEvent} from 'actions/telemetry_actions'; + +import './invite_members_link.scss'; + +type Props = { + inviteURL: string; +} + +const InviteMembersLink = (props: Props) => { + const copyText = useCopyText({ + trackCallback: () => trackEvent('first_admin_setup', 'admin_setup_click_copy_invite_link'), + text: props.inviteURL, + }); + const intl = useIntl(); + + return ( +
+ + +
+ ); +}; + +export default InviteMembersLink; diff --git a/webapp/channels/src/components/preparing_workspace/mixins.scss b/webapp/channels/src/components/preparing_workspace/mixins.scss new file mode 100644 index 0000000000..b3ca03bce8 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/mixins.scss @@ -0,0 +1,12 @@ +@mixin input { + width: 452px; + padding: 12px 16px; + border: 2px solid rgba(var(--center-channel-color-rgb), 0.16); + border-radius: 4px; + font-size: 16px; + + &:active, + &:focus { + border: 2px solid var(--button-bg); + } +} diff --git a/webapp/channels/src/components/preparing_workspace/organization.scss b/webapp/channels/src/components/preparing_workspace/organization.scss new file mode 100644 index 0000000000..c063010404 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/organization.scss @@ -0,0 +1,63 @@ +@import 'utils/variables'; +@import 'utils/mixins'; +@import './mixins'; + +.Organization-body { + display: flex; +} + +.Organization-form-wrapper { + position: relative; +} + +.Organization-left-col { + width: 210px; + min-width: 210px; +} + +.Organization-right-col { + display: flex; + flex-direction: column; + justify-content: center; +} + +.Organization { + &__input { + @include input; + } + + &__status { + display: flex; + align-items: center; + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 12px; + + &--error { + margin-top: 8px; + color: var(--dnd-indicator); + } + } + + &__progress-path { + position: absolute; + top: -25px; + left: -55px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + text-align: center; + } + + &__content { + margin-left: 200px; + } +} + +@media screen and (max-width: 700px) { + .Organization-left-col { + display: none; + } +} + +@include simple-in-and-out("Organization"); diff --git a/webapp/channels/src/components/preparing_workspace/organization.tsx b/webapp/channels/src/components/preparing_workspace/organization.tsx new file mode 100644 index 0000000000..684c6dc4d9 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/organization.tsx @@ -0,0 +1,206 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useEffect, useRef, ChangeEvent} from 'react'; +import {CSSTransition} from 'react-transition-group'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; + +import debounce from 'lodash/debounce'; + +import OrganizationSVG from 'components/common/svg_images_components/organization-building_svg'; +import QuickInput from 'components/quick_input'; + +import {trackEvent} from 'actions/telemetry_actions'; + +import {getTeams} from 'mattermost-redux/actions/teams'; +import {getActiveTeamsList} from 'mattermost-redux/selectors/entities/teams'; +import {Team} from '@mattermost/types/teams'; + +import {teamNameToUrl} from 'utils/url'; +import Constants from 'utils/constants'; + +import OrganizationStatus, {TeamApiError} from './organization_status'; +import {Animations, mapAnimationReasonToClass, Form, PreparingWorkspacePageProps} from './steps'; +import PageLine from './page_line'; +import Title from './title'; +import Description from './description'; +import PageBody from './page_body'; + +import './organization.scss'; + +type Props = PreparingWorkspacePageProps & { + organization: Form['organization']; + setOrganization: (organization: Form['organization']) => void; + className?: string; + createTeam: (OrganizationName: string) => Promise<{error: string | null; newTeam: Team | null}>; + updateTeam: (teamToUpdate: Team) => Promise<{error: string | null; updatedTeam: Team | null}>; + setInviteId: (inviteId: string) => void; +} + +const reportValidationError = debounce(() => { + trackEvent('first_admin_setup', 'validate_organization_error'); +}, 700, {leading: false}); + +const Organization = (props: Props) => { + const {formatMessage} = useIntl(); + const dispatch = useDispatch(); + + const [triedNext, setTriedNext] = useState(false); + const inputRef = useRef(); + const validation = teamNameToUrl(props.organization || ''); + const teamApiError = useRef(null); + + useEffect(props.onPageView, []); + + const teams = useSelector(getActiveTeamsList); + useEffect(() => { + if (!teams) { + dispatch(getTeams(0, 60)); + } + }, [teams]); + + const setApiCallError = () => { + teamApiError.current = TeamApiError; + }; + + const updateTeamNameFromOrgName = async () => { + if (!inputRef.current?.value) { + return; + } + const name = inputRef.current?.value.trim(); + + const currentTeam = teams[0]; + + if (currentTeam && name && name !== currentTeam.display_name) { + const {error} = await props.updateTeam({...currentTeam, display_name: name}); + if (error !== null) { + setApiCallError(); + } + } + }; + + const createTeamFromOrgName = async () => { + if (!inputRef.current?.value) { + return; + } + const name = inputRef.current?.value.trim(); + + if (name) { + const {error, newTeam} = await props.createTeam(name); + if (error !== null || newTeam === null) { + props.setInviteId(''); + setApiCallError(); + return; + } + props.setInviteId(newTeam.invite_id); + } + }; + + const handleOnChange = (e: ChangeEvent) => { + props.setOrganization(e.target.value); + teamApiError.current = null; + }; + + const onNext = (e?: React.KeyboardEvent | React.MouseEvent) => { + if (e && (e as React.KeyboardEvent).key) { + if ((e as React.KeyboardEvent).key !== Constants.KeyCodes.ENTER[0]) { + return; + } + } + if (!triedNext) { + setTriedNext(true); + } + + // if there is already a team, maybe because a page reload, then just update the teamname + const thereIsAlreadyATeam = teams.length > 0; + teamApiError.current = null; + + if (!validation.error && !thereIsAlreadyATeam) { + createTeamFromOrgName(); + } else if (!validation.error && thereIsAlreadyATeam) { + updateTeamNameFromOrgName(); + } + + if (validation.error || teamApiError.current) { + reportValidationError(); + return; + } + props.next?.(); + }; + + let className = 'Organization-body'; + if (props.className) { + className += ' ' + props.className; + } + return ( + +
+
+
+
+ + +
+
+ {props.previous} + + <FormattedMessage + id={'onboarding_wizard.organization.title'} + defaultMessage='What’s the name of your organization?' + /> + + + + + + handleOnChange(e)} + onKeyUp={onNext} + autoFocus={true} + ref={inputRef as unknown as any} + /> + {triedNext ? : null} + + +
+
+
+
+
+ ); +}; +export default Organization; diff --git a/webapp/channels/src/components/preparing_workspace/organization_status.test.tsx b/webapp/channels/src/components/preparing_workspace/organization_status.test.tsx new file mode 100644 index 0000000000..e7d65bfd6b --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/organization_status.test.tsx @@ -0,0 +1,46 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {render} from '@testing-library/react'; +import {BadUrlReasons} from 'utils/url'; +import OrganizationStatus, {TeamApiError} from './organization_status'; +import {withIntl} from 'tests/helpers/intl-test-helper'; + +describe('components/preparing-workspace/organization_status', () => { + const defaultProps = { + error: null, + }; + + it('should match snapshot', () => { + const {container} = render(); + expect(container.firstChild).toMatchSnapshot(); + }); + + it('should render no error message when error prop is null', () => { + const {queryByText, container} = render(); + expect((container.getElementsByClassName('Organization__status').length)).toBe(1); + expect(queryByText(/empty/i)).not.toBeInTheDocument(); + expect(queryByText(/team api error/i)).not.toBeInTheDocument(); + expect(queryByText(/length/i)).not.toBeInTheDocument(); + expect(queryByText(/reserved/i)).not.toBeInTheDocument(); + }); + + it('should render an error message for an empty organization name', () => { + const component = withIntl(); + const {getByText} = render(component); + expect(getByText(/You must enter an organization name/i)).toBeInTheDocument(); + }); + + it('should render an error message for a team API error', () => { + const component = withIntl(); + const {getByText} = render(component); + expect(getByText(/There was an error, please try again/i)).toBeInTheDocument(); + }); + + it('should render an error message for an organization name with invalid length', () => { + const component = withIntl(); + const {getByText} = render(component); + expect(getByText(/Organization name must be between 2 and 64 characters/i)).toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/preparing_workspace/organization_status.tsx b/webapp/channels/src/components/preparing_workspace/organization_status.tsx new file mode 100644 index 0000000000..d695a2ad26 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/organization_status.tsx @@ -0,0 +1,83 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import {BadUrlReasons, UrlValidationCheck} from 'utils/url'; +import Constants, {DocLinks} from 'utils/constants'; +import ExternalLink from 'components/external_link'; + +export const TeamApiError = 'team_api_error'; + +const OrganizationStatus = (props: {error: (UrlValidationCheck['error'] | typeof TeamApiError | null)}): JSX.Element => { + let children = null; + let className = 'Organization__status'; + if (props.error) { + className += ' Organization__status--error'; + switch (props.error) { + case BadUrlReasons.Empty: + children = ( + + ); + break; + case TeamApiError: + children = ( + + ); + break; + case BadUrlReasons.Length: + children = ( + + ); + break; + case BadUrlReasons.Reserved: + children = ( + ( + + {chunks} + + ), + }} + /> + ); + break; + default: + children = ( + + ); + break; + } + } + return
{children}
; +}; + +export default OrganizationStatus; diff --git a/webapp/channels/src/components/preparing_workspace/page_line.scss b/webapp/channels/src/components/preparing_workspace/page_line.scss new file mode 100644 index 0000000000..12801e1f67 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/page_line.scss @@ -0,0 +1,10 @@ +.PageLine { + position: relative; + left: 100px; + width: 1px; + background-color: rgba(var(--center-channel-color-rgb), 0.24); + + &--no-left { + left: initial; + } +} diff --git a/webapp/channels/src/components/preparing_workspace/page_line.tsx b/webapp/channels/src/components/preparing_workspace/page_line.tsx new file mode 100644 index 0000000000..ebbb9ee024 --- /dev/null +++ b/webapp/channels/src/components/preparing_workspace/page_line.tsx @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import './page_line.scss'; + +type Props = { + style?: Record; + noLeft?: boolean; +} +const PageLine = (props: Props) => { + let className = 'PageLine'; + if (props.noLeft) { + className += ' PageLine--no-left'; + } + const styles: Record = {}; + if (props?.style) { + Object.assign(styles, props.style); + } + if (!styles.height) { + styles.height = '100vh'; + } + if ((!props.style?.height && styles.height === '100vh') && !styles.marginTop) { + styles.marginTop = '50px'; + } + return ( +
+ ); +}; + +export default PageLine; diff --git a/webapp/channels/src/components/preparing_workspace/plugins.scss b/webapp/channels/src/components/preparing_workspace/plugins.scss index fa74dc5718..0a5465564e 100644 --- a/webapp/channels/src/components/preparing_workspace/plugins.scss +++ b/webapp/channels/src/components/preparing_workspace/plugins.scss @@ -4,6 +4,9 @@ margin-top: 24px; } +.plugins-skip-btn { + margin-left: 8px; +} // preempt cards wrapping @media screen and (max-width: 900px) { .Plugins-body { diff --git a/webapp/channels/src/components/preparing_workspace/plugins.tsx b/webapp/channels/src/components/preparing_workspace/plugins.tsx index b3b1168015..caf04e794e 100644 --- a/webapp/channels/src/components/preparing_workspace/plugins.tsx +++ b/webapp/channels/src/components/preparing_workspace/plugins.tsx @@ -21,15 +21,16 @@ import {Animations, mapAnimationReasonToClass, Form, PreparingWorkspacePageProps import Title from './title'; import Description from './description'; import PageBody from './page_body'; - import SingleColumnLayout from './single_column_layout'; +import PageLine from './page_line'; import './plugins.scss'; type Props = PreparingWorkspacePageProps & { options: Form['plugins']; setOption: (option: keyof Form['plugins']) => void; className?: string; + isSelfHosted: boolean; } const Plugins = (props: Props) => { const {formatMessage} = useIntl(); @@ -44,6 +45,34 @@ const Plugins = (props: Props) => { if (props.className) { className += ' ' + props.className; } + + let title = ( + + ); + let description = ( + + ); + if (props.isSelfHosted) { + title = ( + + ); + description = ( + + ); + } + return ( { >
+ {props.previous} - <FormattedMessage - id={'onboarding_wizard.plugins.title'} - defaultMessage='Welcome to Mattermost!' - /> - <div className='subtitle'> - <CelebrateSVG/> - <FormattedMessage - id={'onboarding_wizard.plugins.subtitle'} - defaultMessage='(almost there!)' - /> - </div> + {title} + {!props.isSelfHosted && ( + <div className='subtitle'> + <CelebrateSVG/> + <FormattedMessage + id={'onboarding_wizard.cloud_plugins.subtitle'} + defaultMessage='(almost there!)' + /> + </div> + + )} - - - + {description} { />
+
diff --git a/webapp/channels/src/components/preparing_workspace/preparing_workspace.scss b/webapp/channels/src/components/preparing_workspace/preparing_workspace.scss index c91dd0a1fe..99187c301b 100644 --- a/webapp/channels/src/components/preparing_workspace/preparing_workspace.scss +++ b/webapp/channels/src/components/preparing_workspace/preparing_workspace.scss @@ -63,6 +63,21 @@ .primary-button { @include primary-button; @include button-medium; + + box-sizing: border-box; + border: 2px solid var(--button-bg); + } + + .primary-button[disabled] { + box-sizing: border-box; + border: 2px solid rgba(var(--center-channel-color-rgb), 0.01); + } + + .link-style { + @include link; + + background: transparent; + font-size: 14px; } .child-page { @@ -70,6 +85,43 @@ position: absolute; height: 100vh; } + + &__invite-members-illustration { + position: absolute; + top: 25%; + right: -651px; + animation-duration: 0.3s; + animation-fill-mode: forwards; + animation-timing-function: ease-in-out; + } +} + +.enter { + animation-name: slideInRight; +} + +.exit { + animation-name: slideOutRight; +} + +@keyframes slideInRight { + from { + right: -651px; + } + + to { + right: 0; + } +} + +@keyframes slideOutRight { + from { + right: 0; + } + + to { + right: -651px; + } } .PreparingWorkspacePageContainer { diff --git a/webapp/channels/src/components/preparing_workspace/preparing_workspace.tsx b/webapp/channels/src/components/preparing_workspace/preparing_workspace.tsx index d554090d68..268e21c55e 100644 --- a/webapp/channels/src/components/preparing_workspace/preparing_workspace.tsx +++ b/webapp/channels/src/components/preparing_workspace/preparing_workspace.tsx @@ -1,23 +1,24 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useState, useCallback, useEffect, useRef} from 'react'; +import React, {useState, useCallback, useEffect, useRef, useMemo} from 'react'; import {useDispatch, useSelector} from 'react-redux'; import {RouterProps} from 'react-router-dom'; -import {useIntl} from 'react-intl'; +import {FormattedMessage, useIntl} from 'react-intl'; import {GeneralTypes} from 'mattermost-redux/action_types'; import {General} from 'mattermost-redux/constants'; import {getFirstAdminSetupComplete as getFirstAdminSetupCompleteAction} from 'mattermost-redux/actions/general'; import {ActionResult} from 'mattermost-redux/types/actions'; import {Team} from '@mattermost/types/teams'; -import {getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences'; import {isFirstAdmin} from 'mattermost-redux/selectors/entities/users'; import {getCurrentTeam, getMyTeams} from 'mattermost-redux/selectors/entities/teams'; -import {getFirstAdminSetupComplete, getConfig} from 'mattermost-redux/selectors/entities/general'; +import {getFirstAdminSetupComplete, getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; import {Client4} from 'mattermost-redux/client'; import Constants from 'utils/constants'; +import {getSiteURL, teamNameToUrl} from 'utils/url'; +import {makeNewTeam} from 'utils/team_utils'; import {pageVisited, trackEvent} from 'actions/telemetry_actions'; @@ -35,10 +36,14 @@ import { mapStepToPageView, mapStepToSubmitFail, PLUGIN_NAME_TO_ID_MAP, + mapStepToPrevious, } from './steps'; +import Organization from './organization'; import Plugins from './plugins'; import Progress from './progress'; +import InviteMembers from './invite_members'; +import InviteMembersIllustration from './invite_members_illustration'; import LaunchingWorkspace, {START_TRANSITIONING_OUT} from './launching_workspace'; import './preparing_workspace.scss'; @@ -58,6 +63,7 @@ const WAIT_FOR_REDIRECT_TIME = 2000 - START_TRANSITIONING_OUT; export type Actions = { createTeam: (team: Team) => ActionResult; + updateTeam: (team: Team) => ActionResult; checkIfTeamExists: (teamName: string) => ActionResult; getProfiles: (page: number, perPage: number, options: Record) => ActionResult; } @@ -81,12 +87,16 @@ function makeSubmitFail(step: WizardStep) { } const trackSubmitFail = { + [WizardSteps.Organization]: makeSubmitFail(WizardSteps.Organization), [WizardSteps.Plugins]: makeSubmitFail(WizardSteps.Plugins), + [WizardSteps.InviteMembers]: makeSubmitFail(WizardSteps.InviteMembers), [WizardSteps.LaunchingWorkspace]: makeSubmitFail(WizardSteps.LaunchingWorkspace), }; const onPageViews = { + [WizardSteps.Organization]: makeOnPageView(WizardSteps.Organization), [WizardSteps.Plugins]: makeOnPageView(WizardSteps.Plugins), + [WizardSteps.InviteMembers]: makeOnPageView(WizardSteps.InviteMembers), [WizardSteps.LaunchingWorkspace]: makeOnPageView(WizardSteps.LaunchingWorkspace), }; @@ -98,28 +108,35 @@ const PreparingWorkspace = (props: Props) => { defaultMessage: 'Something went wrong. Please try again.', }); const isUserFirstAdmin = useSelector(isFirstAdmin); - const useCaseOnboarding = useSelector(getUseCaseOnboarding); const currentTeam = useSelector(getCurrentTeam); const myTeams = useSelector(getMyTeams); // In cloud instances created from portal, // new admin user has a team in myTeams but not in currentTeam. - const team = currentTeam || myTeams?.[0]; + let team = currentTeam || myTeams?.[0]; const config = useSelector(getConfig); const pluginsEnabled = config.PluginsEnabled === 'true'; const showOnMountTimeout = useRef(); + const configSiteUrl = config.SiteURL; + const isSelfHosted = useSelector(getLicense).Cloud !== 'true'; const stepOrder = [ + isSelfHosted && WizardSteps.Organization, pluginsEnabled && WizardSteps.Plugins, + isSelfHosted && WizardSteps.InviteMembers, WizardSteps.LaunchingWorkspace, ].filter((x) => Boolean(x)) as WizardStep[]; + // first steporder that is not false + const firstShowablePage = stepOrder[0]; + const firstAdminSetupComplete = useSelector(getFirstAdminSetupComplete); const [[mostRecentStep, currentStep], setStepHistory] = useState<[WizardStep, WizardStep]>([stepOrder[0], stepOrder[0]]); const [submissionState, setSubmissionState] = useState(SubmissionStates.Presubmit); + const browserSiteUrl = useMemo(getSiteURL, []); const [form, setForm] = useState({ ...emptyForm, }); @@ -188,13 +205,44 @@ const PreparingWorkspace = (props: Props) => { trackSubmitFail[redirectTo](); }, []); + const createTeam = async (OrganizationName: string): Promise<{error: string | null; newTeam: Team | null}> => { + const data = await props.actions.createTeam(makeNewTeam(OrganizationName, teamNameToUrl(OrganizationName || '').url)); + if (data.error) { + return {error: genericSubmitError, newTeam: null}; + } + return {error: null, newTeam: data.data}; + }; + + const updateTeam = async (teamToUpdate: Team): Promise<{error: string | null; updatedTeam: Team | null}> => { + const data = await props.actions.updateTeam(teamToUpdate); + if (data.error) { + return {error: genericSubmitError, updatedTeam: null}; + } + return {error: null, updatedTeam: data.data}; + }; + const sendForm = async () => { const sendFormStart = Date.now(); setSubmissionState(SubmissionStates.Submitting); + if (form.organization && !isSelfHosted) { + try { + const {error, newTeam} = await createTeam(form.organization); + if (error !== null) { + redirectWithError(WizardSteps.Organization, genericSubmitError); + return; + } + team = newTeam as Team; + } catch (e) { + redirectWithError(WizardSteps.Organization, genericSubmitError); + return; + } + } + // send plugins const {skipped: skippedPlugins, ...pluginChoices} = form.plugins; let pluginsToSetup: string[] = []; + if (!skippedPlugins) { pluginsToSetup = Object.entries(pluginChoices).reduce( (acc: string[], [k, v]): string[] => (v ? [...acc, PLUGIN_NAME_TO_ID_MAP[k as keyof Omit]] : acc), [], @@ -204,8 +252,10 @@ const PreparingWorkspace = (props: Props) => { // This endpoint sets setup complete state, so we need to make this request // even if admin skipped submitting plugins. const completeSetupRequest = { + organization: form.organization, install_plugins: pluginsToSetup, }; + try { await Client4.completeSetup(completeSetupRequest); dispatch({type: GeneralTypes.FIRST_ADMIN_COMPLETE_SETUP_RECEIVED, data: true}); @@ -221,6 +271,7 @@ const PreparingWorkspace = (props: Props) => { const sendFormEnd = Date.now(); const timeToWait = WAIT_FOR_REDIRECT_TIME - (sendFormEnd - sendFormStart); + if (timeToWait > 0) { setTimeout(goToChannels, timeToWait); } else { @@ -236,7 +287,8 @@ const PreparingWorkspace = (props: Props) => { }, [submissionState]); const adminRevisitedPage = firstAdminSetupComplete && submissionState === SubmissionStates.Presubmit; - const shouldRedirect = !isUserFirstAdmin || adminRevisitedPage || !useCaseOnboarding; + const shouldRedirect = !isUserFirstAdmin || adminRevisitedPage; + useEffect(() => { if (shouldRedirect) { props.history.push('/'); @@ -256,6 +308,24 @@ const PreparingWorkspace = (props: Props) => { return stepIndex > currentStepIndex ? Animations.Reasons.ExitToBefore : Animations.Reasons.ExitToAfter; }; + const goPrevious = useCallback((e?: React.KeyboardEvent | React.MouseEvent) => { + if (e && (e as React.KeyboardEvent).key) { + const key = (e as React.KeyboardEvent).key; + if (key !== Constants.KeyCodes.ENTER[0] && key !== Constants.KeyCodes.SPACE[0]) { + return; + } + } + if (submissionState !== SubmissionStates.Presubmit && submissionState !== SubmissionStates.SubmitFail) { + return; + } + const stepIndex = stepOrder.indexOf(currentStep); + if (stepIndex <= 0) { + return; + } + trackEvent('first_admin_setup', mapStepToPrevious(currentStep)); + setStepHistory([currentStep, stepOrder[stepIndex - 1]]); + }, [currentStep]); + const skipPlugins = useCallback((skipped: boolean) => { if (skipped === form.plugins.skipped) { return; @@ -269,6 +339,46 @@ const PreparingWorkspace = (props: Props) => { }); }, [form]); + const skipTeamMembers = useCallback((skipped: boolean) => { + if (skipped === form.teamMembers.skipped) { + return; + } + setForm({ + ...form, + teamMembers: { + ...form.teamMembers, + skipped, + }, + }); + }, [form]); + + const getInviteMembersAnimationClass = useCallback(() => { + if (currentStep === WizardSteps.InviteMembers) { + return 'enter'; + } else if (mostRecentStep === WizardSteps.InviteMembers) { + return 'exit'; + } + return ''; + }, [currentStep]); + + let previous: React.ReactNode = ( +
+ + +
+ ); + if (currentStep === firstShowablePage) { + previous = null; + } + return (
{submissionState === SubmissionStates.SubmitFail && submitError && ( @@ -291,17 +401,49 @@ const PreparingWorkspace = (props: Props) => { transitionSpeed={Animations.PAGE_SLIDE} />
+ { + setForm({ + ...form, + organization, + }); + }} + setInviteId={(inviteId: string) => { + setForm({ + ...form, + teamMembers: { + ...form.teamMembers, + inviteId, + }, + }); + }} + className='child-page' + createTeam={createTeam} + updateTeam={updateTeam} + /> + { const pluginChoices = {...form.plugins}; delete pluginChoices.skipped; - setSubmissionState(SubmissionStates.UserRequested); + if (!isSelfHosted) { + setSubmissionState(SubmissionStates.UserRequested); + } makeNext(WizardSteps.Plugins)(pluginChoices); skipPlugins(false); }} skip={() => { - setSubmissionState(SubmissionStates.UserRequested); + if (!isSelfHosted) { + setSubmissionState(SubmissionStates.UserRequested); + } makeNext(WizardSteps.Plugins, true)(); skipPlugins(true); }} @@ -319,12 +461,40 @@ const PreparingWorkspace = (props: Props) => { transitionDirection={getTransitionDirection(WizardSteps.Plugins)} className='child-page' /> + { + skipTeamMembers(false); + const inviteMembersTracking = { + inviteCount: form.teamMembers.invites.length, + }; + setSubmissionState(SubmissionStates.UserRequested); + makeNext(WizardSteps.InviteMembers)(inviteMembersTracking); + }} + skip={() => { + skipTeamMembers(true); + setSubmissionState(SubmissionStates.UserRequested); + makeNext(WizardSteps.InviteMembers, true)(); + }} + previous={previous} + show={shouldShowPage(WizardSteps.InviteMembers)} + transitionDirection={getTransitionDirection(WizardSteps.InviteMembers)} + disableEdits={submissionState !== SubmissionStates.Presubmit && submissionState !== SubmissionStates.SubmitFail} + className='child-page' + teamInviteId={team?.invite_id || form.teamMembers.inviteId} + configSiteUrl={configSiteUrl} + formUrl={form.url} + browserSiteUrl={browserSiteUrl} + />
+
+ +
); }; diff --git a/webapp/channels/src/components/preparing_workspace/single_column_layout.scss b/webapp/channels/src/components/preparing_workspace/single_column_layout.scss index 357faabdf4..afff27dcfe 100644 --- a/webapp/channels/src/components/preparing_workspace/single_column_layout.scss +++ b/webapp/channels/src/components/preparing_workspace/single_column_layout.scss @@ -4,5 +4,4 @@ height: 100vh; flex-direction: column; align-items: flex-start; - justify-content: center; } diff --git a/webapp/channels/src/components/preparing_workspace/steps.ts b/webapp/channels/src/components/preparing_workspace/steps.ts index ed52d984af..cbb78da5b6 100644 --- a/webapp/channels/src/components/preparing_workspace/steps.ts +++ b/webapp/channels/src/components/preparing_workspace/steps.ts @@ -4,7 +4,9 @@ import deepFreeze from 'mattermost-redux/utils/deep_freeze'; export const WizardSteps = { + Organization: 'Organization', Plugins: 'Plugins', + InviteMembers: 'InviteMembers', LaunchingWorkspace: 'LaunchingWorkspace', } as const; @@ -20,8 +22,12 @@ export const Animations = { export function mapStepToNextName(step: WizardStep): string { switch (step) { + case WizardSteps.Organization: + return 'admin_onboarding_next_organization'; case WizardSteps.Plugins: return 'admin_onboarding_next_plugins'; + case WizardSteps.InviteMembers: + return 'admin_onboarding_next_invite_members'; case WizardSteps.LaunchingWorkspace: return 'admin_onboarding_next_transitioning_out'; default: @@ -31,8 +37,12 @@ export function mapStepToNextName(step: WizardStep): string { export function mapStepToPrevious(step: WizardStep): string { switch (step) { + case WizardSteps.Organization: + return 'admin_onboarding_previous_organization'; case WizardSteps.Plugins: return 'admin_onboarding_previous_plugins'; + case WizardSteps.InviteMembers: + return 'admin_onboarding_previous_invite_members'; case WizardSteps.LaunchingWorkspace: return 'admin_onboarding_previous_transitioning_out'; default: @@ -42,8 +52,12 @@ export function mapStepToPrevious(step: WizardStep): string { export function mapStepToPageView(step: WizardStep): string { switch (step) { + case WizardSteps.Organization: + return 'pageview_admin_onboarding_organization'; case WizardSteps.Plugins: return 'pageview_admin_onboarding_plugins'; + case WizardSteps.InviteMembers: + return 'pageview_admin_onboarding_invite_members'; case WizardSteps.LaunchingWorkspace: return 'pageview_admin_onboarding_transitioning_out'; default: @@ -53,8 +67,12 @@ export function mapStepToPageView(step: WizardStep): string { export function mapStepToSubmitFail(step: WizardStep): string { switch (step) { + case WizardSteps.Organization: + return 'admin_onboarding_organization_submit_fail'; case WizardSteps.Plugins: return 'admin_onboarding_plugins_submit_fail'; + case WizardSteps.InviteMembers: + return 'admin_onboarding_invite_members_submit_fail'; case WizardSteps.LaunchingWorkspace: return 'admin_onboarding_transitioning_out_submit_fail'; default: @@ -64,8 +82,12 @@ export function mapStepToSubmitFail(step: WizardStep): string { export function mapStepToSkipName(step: WizardStep): string { switch (step) { + case WizardSteps.Organization: + return 'admin_onboarding_skip_organization'; case WizardSteps.Plugins: return 'admin_onboarding_skip_plugins'; + case WizardSteps.InviteMembers: + return 'admin_onboarding_skip_invite_members'; case WizardSteps.LaunchingWorkspace: return 'admin_onboarding_skip_transitioning_out'; default: @@ -128,12 +150,14 @@ export type Form = { skipped: boolean; }; teamMembers: { + inviteId: string; invites: string[]; skipped: boolean; }; } export const emptyForm = deepFreeze({ + organization: '', inferredProtocol: null, urlSkipped: false, useCase: { @@ -156,6 +180,7 @@ export const emptyForm = deepFreeze({ skipped: false, }, teamMembers: { + inviteId: '', invites: [], skipped: false, }, @@ -165,7 +190,7 @@ export type PreparingWorkspacePageProps = { transitionDirection: AnimationReason; next?: () => void; skip?: () => void; - previous?: JSX.Element; + previous?: React.ReactNode; show: boolean; onPageView: () => void; } diff --git a/webapp/channels/src/components/profile_popover/index.ts b/webapp/channels/src/components/profile_popover/index.ts index 0af4f863f1..86c77f0203 100644 --- a/webapp/channels/src/components/profile_popover/index.ts +++ b/webapp/channels/src/components/profile_popover/index.ts @@ -50,12 +50,12 @@ function getDefaultChannelId(state: GlobalState) { return selectedPost.exists ? selectedPost.channel_id : getCurrentChannelId(state); } -function checkUserInCall(state: GlobalState, userId: string) { +export function checkUserInCall(state: GlobalState, userId: string) { let isUserInCall = false; const calls = getCalls(state); Object.keys(calls).forEach((channelId) => { - const usersInCall = calls[channelId]; + const usersInCall = calls[channelId] || []; for (const user of usersInCall) { if (user.id === userId) { diff --git a/webapp/channels/src/components/profile_popover/profile_popover.test.tsx b/webapp/channels/src/components/profile_popover/profile_popover.test.tsx index 8e3a42c8cd..75eab71c80 100644 --- a/webapp/channels/src/components/profile_popover/profile_popover.test.tsx +++ b/webapp/channels/src/components/profile_popover/profile_popover.test.tsx @@ -9,6 +9,7 @@ import {General} from 'mattermost-redux/constants'; import {CustomStatusDuration} from '@mattermost/types/users'; import ProfilePopover from 'components/profile_popover/profile_popover'; +import {checkUserInCall} from 'components/profile_popover'; import Pluggable from 'plugins/pluggable'; @@ -284,3 +285,52 @@ describe('components/ProfilePopover', () => { expect(wrapper).toMatchSnapshot(); }); }); + +describe('checkUserInCall', () => { + test('missing state', () => { + expect(checkUserInCall({ + 'plugins-com.mattermost.calls': {}, + } as any, 'userA')).toBe(false); + }); + + test('call state missing', () => { + expect(checkUserInCall({ + 'plugins-com.mattermost.calls': { + voiceConnectedProfiles: { + channelID: null, + }, + }, + } as any, 'userA')).toBe(false); + }); + + test('user not in call', () => { + expect(checkUserInCall({ + 'plugins-com.mattermost.calls': { + voiceConnectedProfiles: { + channelID: [ + { + id: 'userB', + }, + ], + }, + }, + } as any, 'userA')).toBe(false); + }); + + test('user in call', () => { + expect(checkUserInCall({ + 'plugins-com.mattermost.calls': { + voiceConnectedProfiles: { + channelID: [ + { + id: 'userB', + }, + { + id: 'userA', + }, + ], + }, + }, + } as any, 'userA')).toBe(true); + }); +}); diff --git a/webapp/channels/src/components/root/root.tsx b/webapp/channels/src/components/root/root.tsx index eb41c2d0fe..73f78f6af6 100644 --- a/webapp/channels/src/components/root/root.tsx +++ b/webapp/channels/src/components/root/root.tsx @@ -10,7 +10,7 @@ import classNames from 'classnames'; import {Client4} from 'mattermost-redux/client'; import {rudderAnalytics, RudderTelemetryHandler} from 'mattermost-redux/client/rudder'; import {General} from 'mattermost-redux/constants'; -import {Theme, getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences'; +import {Theme} from 'mattermost-redux/selectors/entities/preferences'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getCurrentUser, isCurrentUserSystemAdmin, checkIsFirstAdmin} from 'mattermost-redux/selectors/entities/users'; import {setUrl} from 'mattermost-redux/actions/general'; @@ -89,6 +89,8 @@ import {ActionResult} from 'mattermost-redux/types/actions'; import WelcomePostRenderer from 'components/welcome_post_renderer'; +import {getMyTeams} from 'mattermost-redux/selectors/entities/teams'; + import {applyLuxonDefaults} from './effects'; import RootProvider from './root_provider'; @@ -358,8 +360,8 @@ export default class Root extends React.PureComponent { return; } - const useCaseOnboarding = getUseCaseOnboarding(storeState); - if (!useCaseOnboarding) { + const myTeams = getMyTeams(storeState); + if (myTeams.length > 0) { GlobalActions.redirectUserToDefaultTeam(); return; } diff --git a/webapp/channels/src/components/root/root_redirect/index.ts b/webapp/channels/src/components/root/root_redirect/index.ts index 7575f7c5a4..eca15abc20 100644 --- a/webapp/channels/src/components/root/root_redirect/index.ts +++ b/webapp/channels/src/components/root/root_redirect/index.ts @@ -6,7 +6,6 @@ import {connect} from 'react-redux'; import {getFirstAdminSetupComplete} from 'mattermost-redux/actions/general'; import {getCurrentUserId, isCurrentUserSystemAdmin, isFirstAdmin} from 'mattermost-redux/selectors/entities/users'; -import {getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences'; import {GenericAction} from 'mattermost-redux/types/actions'; import {GlobalState} from 'types/store'; @@ -14,11 +13,7 @@ import {GlobalState} from 'types/store'; import RootRedirect, {Props} from './root_redirect'; function mapStateToProps(state: GlobalState) { - const useCaseOnboarding = getUseCaseOnboarding(state); - let isElegibleForFirstAdmingOnboarding = useCaseOnboarding; - if (isElegibleForFirstAdmingOnboarding) { - isElegibleForFirstAdmingOnboarding = isCurrentUserSystemAdmin(state); - } + const isElegibleForFirstAdmingOnboarding = isCurrentUserSystemAdmin(state); return { currentUserId: getCurrentUserId(state), isElegibleForFirstAdmingOnboarding, diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.scss index 089f296671..e6910940f0 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.scss +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.scss @@ -1,6 +1,6 @@ .SelfHostedExpansionRHSCard { display: flex; - max-width: 280px; + width: 280px; flex-direction: column; &__Content { diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/self_hosted_expansion_modal.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/self_hosted_expansion_modal.scss index 5a98adfbbf..1938890b37 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/self_hosted_expansion_modal.scss +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/self_hosted_expansion_modal.scss @@ -58,7 +58,8 @@ } .section-title { - margin-bottom: 24px; + display: block; + margin-bottom: 10px; color: rgba(var(--center-channel-color-rgb), 0.72); font-size: 16px; font-weight: 600; diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx index 9dc1ce0620..7eafc6c9b1 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx @@ -13,6 +13,8 @@ import {ValueOf} from '@mattermost/types/utilities'; import CreditCardSvg from 'components/common/svg_images_components/credit_card_svg'; import IconMessage from 'components/purchase_modal/icon_message'; +import './submitting_page.scss'; + function useConvertProgressToWaitingExplanation(progress: ValueOf, planName: string): React.ReactNode { const intl = useIntl(); switch (progress) { diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting_page.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting_page.scss new file mode 100644 index 0000000000..46179472b8 --- /dev/null +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting_page.scss @@ -0,0 +1,7 @@ +.submitting { + overflow: hidden; + + .processing { + margin-top: 163px; + } +} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.scss index 8986942706..522384347e 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.scss +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.scss @@ -15,5 +15,9 @@ } .self_hosted_expansion_success { - margin-top: 163px; + overflow: hidden; + + .selfHostedExpansionModal__success { + margin-top: 163px; + } } diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx index 9549ef2e24..f8c362780c 100644 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx +++ b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx @@ -7,7 +7,6 @@ import {useHistory} from 'react-router-dom'; import IconMessage from 'components/purchase_modal/icon_message'; import PaymentSuccessStandardSvg from 'components/common/svg_images_components/payment_success_standard_svg'; -import BackgroundSvg from 'components/common/svg_images_components/background_svg'; import {ConsolePages} from 'utils/constants'; @@ -71,9 +70,6 @@ export default function SelfHostedExpansionSuccessPage(props: Props) { formattedButtonText={formattedButtonText} buttonHandler={props.onClose} /> -
- -
); } diff --git a/webapp/channels/src/components/signup/signup.test.tsx b/webapp/channels/src/components/signup/signup.test.tsx index dcc56032a0..900e8de8b7 100644 --- a/webapp/channels/src/components/signup/signup.test.tsx +++ b/webapp/channels/src/components/signup/signup.test.tsx @@ -7,8 +7,6 @@ import {IntlProvider} from 'react-intl'; import {BrowserRouter} from 'react-router-dom'; import {act, screen} from '@testing-library/react'; -import * as global_actions from 'actions/global_actions'; - import {mountWithIntl} from 'tests/helpers/intl-test-helper'; import Signup from 'components/signup/signup'; @@ -197,9 +195,6 @@ describe('components/signup/Signup', () => { mockResolvedValueOnce({data: {id: 'userId', password: 'password', email: 'jdoe@mm.com}'}}). // createUser mockResolvedValueOnce({error: {server_error_id: 'api.user.login.not_verified.app_error'}}); // loginById - const mockRedirectUserToDefaultTeam = jest.fn(); - jest.spyOn(global_actions, 'redirectUserToDefaultTeam').mockImplementation(mockRedirectUserToDefaultTeam); - const wrapper = mountWithIntl( @@ -228,7 +223,6 @@ describe('components/signup/Signup', () => { expect(wrapper.find('#input_name').first().props().disabled).toEqual(true); expect(wrapper.find(PasswordInput).first().props().disabled).toEqual(true); - expect(mockRedirectUserToDefaultTeam).not.toHaveBeenCalled(); expect(mockHistoryPush).toHaveBeenCalledWith('/should_verify_email?email=jdoe%40mm.com&teamname=teamName'); }); @@ -238,9 +232,6 @@ describe('components/signup/Signup', () => { mockResolvedValueOnce({data: {id: 'userId', password: 'password', email: 'jdoe@mm.com}'}}). // createUser mockResolvedValueOnce({}); // loginById - const mockRedirectUserToDefaultTeam = jest.fn(); - jest.spyOn(global_actions, 'redirectUserToDefaultTeam').mockImplementation(mockRedirectUserToDefaultTeam); - const wrapper = mountWithIntl( @@ -268,8 +259,6 @@ describe('components/signup/Signup', () => { expect(wrapper.find(Input).first().props().disabled).toEqual(true); expect(wrapper.find('#input_name').first().props().disabled).toEqual(true); expect(wrapper.find(PasswordInput).first().props().disabled).toEqual(true); - - expect(mockRedirectUserToDefaultTeam).toHaveBeenCalled(); }); it('should add user to team and redirect when team invite valid and logged in', async () => { diff --git a/webapp/channels/src/components/signup/signup.tsx b/webapp/channels/src/components/signup/signup.tsx index 454d5cd8ae..0407a74895 100644 --- a/webapp/channels/src/components/signup/signup.tsx +++ b/webapp/channels/src/components/signup/signup.tsx @@ -17,7 +17,7 @@ import {getTeamInviteInfo} from 'mattermost-redux/actions/teams'; import {createUser, loadMe, loadMeREST} from 'mattermost-redux/actions/users'; import {DispatchFunc} from 'mattermost-redux/types/actions'; import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; -import {getUseCaseOnboarding, isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences'; +import {isGraphQLEnabled} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {isEmail} from 'mattermost-redux/utils/helpers'; @@ -25,7 +25,6 @@ import {GlobalState} from 'types/store'; import {getGlobalItem} from 'selectors/storage'; -import {redirectUserToDefaultTeam} from 'actions/global_actions'; import {removeGlobalItem, setGlobalItem} from 'actions/storage'; import {addUserToTeamFromInvite} from 'actions/team_actions'; import {trackEvent} from 'actions/telemetry_actions.jsx'; @@ -104,7 +103,6 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { } = config; const {IsLicensed, Cloud} = useSelector(getLicense); const loggedIn = Boolean(useSelector(getCurrentUserId)); - const useCaseOnboarding = useSelector(getUseCaseOnboarding); const usedBefore = useSelector((state: GlobalState) => (!inviteId && !loggedIn && token ? getGlobalItem(state, token, null) : undefined)); const graphQLEnabled = useSelector(isGraphQLEnabled); @@ -310,15 +308,7 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { } else if (inviteId) { getInviteInfo(inviteId); } else if (loggedIn) { - if (useCaseOnboarding) { - // need info about whether admin or not, - // and whether admin has already completed - // first tiem onboarding. Instead of fetching and orchestrating that here, - // let the default root component handle it. - history.push('/'); - } else { - redirectUserToDefaultTeam(); - } + history.push('/'); } } @@ -461,14 +451,12 @@ const Signup = ({onCustomizeHeader}: SignupProps) => { if (redirectTo) { history.push(redirectTo); - } else if (useCaseOnboarding) { + } else { // need info about whether admin or not, // and whether admin has already completed // first tiem onboarding. Instead of fetching and orchestrating that here, // let the default root component handle it. history.push('/'); - } else { - redirectUserToDefaultTeam(); } }; diff --git a/webapp/channels/src/components/team_controller/index.ts b/webapp/channels/src/components/team_controller/index.ts index 693a5d5433..a76a2f9b90 100644 --- a/webapp/channels/src/components/team_controller/index.ts +++ b/webapp/channels/src/components/team_controller/index.ts @@ -36,6 +36,7 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) { const currentUser = getCurrentUser(state); const plugins = state.plugins.components.NeedsTeamComponent; const graphQLEnabled = isGraphQLEnabled(state); + const disableRefetchingOnBrowserFocus = config.DisableRefetchingOnBrowserFocus === 'true'; return { currentUser, @@ -46,6 +47,7 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) { selectedThreadId: getSelectedThreadIdInCurrentTeam(state), mfaRequired: checkIfMFARequired(currentUser, license, config, ownProps.match.url), graphQLEnabled, + disableRefetchingOnBrowserFocus, }; } diff --git a/webapp/channels/src/components/team_controller/team_controller.tsx b/webapp/channels/src/components/team_controller/team_controller.tsx index a520697214..60dbc84758 100644 --- a/webapp/channels/src/components/team_controller/team_controller.tsx +++ b/webapp/channels/src/components/team_controller/team_controller.tsx @@ -70,7 +70,7 @@ function TeamController(props: Props) { const wakeUpIntervalId = setInterval(() => { const currentTime = Date.now(); if ((currentTime - lastTime.current) > WAKEUP_THRESHOLD) { - console.log('computer woke up - fetching latest'); //eslint-disable-line no-console + console.log('computer woke up - reconnecting'); //eslint-disable-line no-console reconnect(); } lastTime.current = currentTime; @@ -92,12 +92,15 @@ function TeamController(props: Props) { props.markChannelAsReadOnFocus(props.currentChannelId); } - const currentTime = Date.now(); - if ((currentTime - blurTime.current) > UNREAD_CHECK_TIME_MILLISECONDS && props.currentTeamId) { - if (props.graphQLEnabled) { - props.fetchChannelsAndMembers(props.currentTeamId); - } else { - props.fetchMyChannelsAndMembersREST(props.currentTeamId); + // Temporary flag to disable refetching of channel members on browser focus + if (!props.disableRefetchingOnBrowserFocus) { + const currentTime = Date.now(); + if ((currentTime - blurTime.current) > UNREAD_CHECK_TIME_MILLISECONDS && props.currentTeamId) { + if (props.graphQLEnabled) { + props.fetchChannelsAndMembers(props.currentTeamId); + } else { + props.fetchMyChannelsAndMembersREST(props.currentTeamId); + } } } } diff --git a/webapp/channels/src/components/terms_of_service/index.ts b/webapp/channels/src/components/terms_of_service/index.ts index 95faca09a1..c22fba415f 100644 --- a/webapp/channels/src/components/terms_of_service/index.ts +++ b/webapp/channels/src/components/terms_of_service/index.ts @@ -6,7 +6,6 @@ import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux'; import {getTermsOfService, updateMyTermsOfServiceStatus} from 'mattermost-redux/actions/users'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; -import {getUseCaseOnboarding} from 'mattermost-redux/selectors/entities/preferences'; import {GlobalState} from '@mattermost/types/store'; import {ActionFunc, GenericAction} from 'mattermost-redux/types/actions'; @@ -26,9 +25,7 @@ type Actions = { function mapStateToProps(state: GlobalState) { const config = getConfig(state); - const useCaseOnboarding = getUseCaseOnboarding(state); return { - useCaseOnboarding, termsEnabled: config.EnableCustomTermsOfService === 'true', emojiMap: getEmojiMap(state), }; diff --git a/webapp/channels/src/components/terms_of_service/terms_of_service.test.tsx b/webapp/channels/src/components/terms_of_service/terms_of_service.test.tsx index d05c91cd84..5c9f58faab 100644 --- a/webapp/channels/src/components/terms_of_service/terms_of_service.test.tsx +++ b/webapp/channels/src/components/terms_of_service/terms_of_service.test.tsx @@ -27,7 +27,6 @@ describe('components/terms_of_service/TermsOfService', () => { location: {search: ''}, termsEnabled: true, emojiMap: {} as EmojiMap, - useCaseOnboarding: false, }; test('should match snapshot', () => { diff --git a/webapp/channels/src/components/terms_of_service/terms_of_service.tsx b/webapp/channels/src/components/terms_of_service/terms_of_service.tsx index 992086d561..f885830f9c 100644 --- a/webapp/channels/src/components/terms_of_service/terms_of_service.tsx +++ b/webapp/channels/src/components/terms_of_service/terms_of_service.tsx @@ -38,7 +38,6 @@ export interface TermsOfServiceProps { ) => {data: UpdateMyTermsOfServiceStatusResponse}; }; emojiMap: EmojiMap; - useCaseOnboarding: boolean; } interface TermsOfServiceState { @@ -111,14 +110,12 @@ export default class TermsOfService extends React.PureComponent { const [integrations, setIntegrations] = useState(); - const plugins: MarketplacePlugin[] = useSelector((state: GlobalState) => state.views.marketplace.plugins); + const marketplacePlugins: MarketplacePlugin[] = useSelector((state: GlobalState) => state.views.marketplace.plugins); + const loadedPlugins = useSelector((state: GlobalState) => state.plugins.plugins); const [illustrationDetails, setIllustrationDetails] = useState(() => { const defaultIllustration = getTemplateDefaultIllustration(template); @@ -130,13 +131,14 @@ const Preview = ({template, className, pluginsEnabled}: PreviewProps) => { const intg = availableIntegrations?. flatMap((integration) => { - return plugins.reduce((acc: Integration[], curr) => { + return marketplacePlugins.reduce((acc: Integration[], curr) => { if (curr.manifest.id === integration.id) { + const installed = Boolean(loadedPlugins[integration.id]); acc.push({ ...integration, name: curr.manifest.name, icon: curr.icon_data, - installed: curr.installed_version !== '', + installed, }); return acc; @@ -149,7 +151,7 @@ const Preview = ({template, className, pluginsEnabled}: PreviewProps) => { if (intg?.length) { setIntegrations(intg); } - }, [plugins, availableIntegrations, pluginsEnabled]); + }, [marketplacePlugins, availableIntegrations, loadedPlugins, pluginsEnabled]); // building accordion items const accordionItemsData: AccordionItemType[] = []; @@ -204,7 +206,7 @@ const Preview = ({template, className, pluginsEnabled}: PreviewProps) => { )], }); } - if (integrations?.length && pluginsEnabled) { + if (pluginsEnabled && integrations?.length) { accordionItemsData.push({ id: 'integrations', icon: , @@ -303,6 +305,7 @@ const StyledPreview = styled(Preview)` width: 387px; height: 416px; padding-right: 32px; + margin-top: 17px; } strong { diff --git a/webapp/channels/src/components/work_templates/components/preview/accordion.tsx b/webapp/channels/src/components/work_templates/components/preview/accordion.tsx index 41394b9dc4..02be467269 100644 --- a/webapp/channels/src/components/work_templates/components/preview/accordion.tsx +++ b/webapp/channels/src/components/work_templates/components/preview/accordion.tsx @@ -12,6 +12,7 @@ const Accordion = styled(LibAccordion)` .accordion-card { margin-bottom: 8px; border-radius: 4px; + border: 1px solid transparent; color: var(--center-channel-color); .accordion-card-header { @@ -46,7 +47,7 @@ const Accordion = styled(LibAccordion)` } &.active { - border: 1px solid var(--denim-button-bg); + border-color: var(--denim-button-bg); .accordion-card-header { color: var(--denim-button-bg); diff --git a/webapp/channels/src/components/work_templates/components/preview/section.tsx b/webapp/channels/src/components/work_templates/components/preview/section.tsx index 00ee7e8f13..3514da9b09 100644 --- a/webapp/channels/src/components/work_templates/components/preview/section.tsx +++ b/webapp/channels/src/components/work_templates/components/preview/section.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {ReactNode, useEffect, useState} from 'react'; +import React, {ReactNode, useCallback, useEffect, useState} from 'react'; import {useIntl} from 'react-intl'; import classnames from 'classnames'; import styled from 'styled-components'; @@ -110,6 +110,28 @@ const IntegrationsPreview = ({items, categoryId}: IntegrationPreviewSectionProps id: 'work_templates.preview.integrations.admin_install.notify', defaultMessage: 'Notify admin to install integrations.', }); + + const makeIntegrationSubtext = useCallback((integration: IntegrationPreviewSectionItemsProps) => { + if (integration.installed) { + return formatMessage({ + id: 'work_templates.preview.integrations.already_installed', + defaultMessage: 'Already installed', + }); + } + + if (!pluginInstallationPossible) { + return formatMessage({ + id: 'work_templates.preview.integrations.app_install', + defaultMessage: 'App Install', + }); + } + + return formatMessage({ + id: 'work_templates.preview.integrations.to_be_installed', + defaultMessage: 'To be installed', + }); + }, [pluginInstallationPossible, formatMessage]); + return (
@@ -119,15 +141,17 @@ const IntegrationsPreview = ({items, categoryId}: IntegrationPreviewSectionProps key={item.id} className={classnames('preview-integrations-plugins-item', {'preview-integrations-plugins-item__readonly': !item.installed && !pluginInstallationPossible})} > -
+
- {item.name} + {item.name}
+ + {makeIntegrationSubtext(item)} +
{item.installed && -
} - {!item.installed &&
} +
}
); })}
@@ -205,6 +229,7 @@ const StyledPreviewSection = styled(PreviewSection)` &-item { display: flex; + align-items: center; width: 128px; height: 48px; flex-basis: 45%; @@ -215,7 +240,7 @@ const StyledPreviewSection = styled(PreviewSection)` opacity: 65%; } - &__icon { + &__illustration { display: flex; width: 24px; height: 24px; @@ -227,22 +252,30 @@ const StyledPreviewSection = styled(PreviewSection)` width: 100%; height: 100%; } - - &_blue { - color: var(--denim-button-bg); - } } &__name { flex-grow: 2; - margin-top: 8px; color: var(--center-channel-text); font-family: 'Open Sans'; font-size: 11px; font-style: normal; font-weight: 600; letter-spacing: 0.02em; - line-height: 22px; + line-height: 16px; + &-sub { + color: rgba(var(--center-channel-color-rgb), 0.72); + font-weight: 400; + font-size: 10px; + } + } + + &__icon { + align-self: flex-start; + + &_blue { + color: var(--denim-button-bg); + } } } } @@ -264,8 +297,8 @@ const StyledPreviewSection = styled(PreviewSection)` } .icon-check-circle::before { - margin-top: 8px; - margin-right: 8px; + margin-top: 2px; + margin-right: 2px; } .icon-download-outline::before { diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index a0fde44cb7..360d514e12 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -895,6 +895,8 @@ "admin.experimental.collapsedThreads.title": "Collapsed Reply Threads", "admin.experimental.defaultTheme.desc": "Set a default theme that applies to all new users on the system.", "admin.experimental.defaultTheme.title": "Default Theme:", + "admin.experimental.disableRefetchingOnBrowserFocus.desc": "When true, Mattermost will not refetch channels and channel members when the browser regains focus. This may result in improved performance for users with many channels and channel members.", + "admin.experimental.disableRefetchingOnBrowserFocus.title": "Disable data refetching on browser refocus:", "admin.experimental.emailBatchingBufferSize.desc": "Specify the maximum number of notifications batched into a single email.", "admin.experimental.emailBatchingBufferSize.example": "E.g.: \"256\"", "admin.experimental.emailBatchingBufferSize.title": "Email Batching Buffer Size:", @@ -3055,7 +3057,7 @@ "cloud_billing.nudge_to_yearly.announcement_bar": "Monthly billing will be discontinued in {days} days . Switch to annual billing", "cloud_billing.nudge_to_yearly.contact_sales": "Contact sales", "cloud_billing.nudge_to_yearly.description": "Monthly billing will be discontinued on {date}. To keep your workspace, switch to annual billing.", - "cloud_billing.nudge_to_yearly.learn_more": "Learn more", + "cloud_billing.nudge_to_yearly.learn_more": "Update billing", "cloud_billing.nudge_to_yearly.title": "Action required: Switch to annual billing to keep your workspace.", "cloud_delinquency.banner.buttonText": "Update billing now", "cloud_delinquency.banner.end_user_notify_admin_button": "Notify admin", @@ -4318,10 +4320,26 @@ "notify_here.question": "By using **@here** you are about to send notifications to up to **{totalMembers} other people**. Are you sure you want to do this?", "notify_here.question_timezone": "By using **@here** you are about to send notifications to up to **{totalMembers} other people** in **{timezones, number} {timezones, plural, one {timezone} other {timezones}}**. Are you sure you want to do this?", "numMembers": "{num, number} {num, plural, one {member} other {members}}", + "onboarding_wizard.cloud_plugins.description": "Mattermost is better when integrated with the tools your team uses for collaboration. Popular tools are below, select the ones your team uses and we'll add them to your workspace. Additional set up may be needed later.", + "onboarding_wizard.cloud_plugins.subtitle": "(almost there!)", + "onboarding_wizard.cloud_plugins.title": "Welcome to Mattermost!", + "onboarding_wizard.invite_members.copied_link": "Link Copied", + "onboarding_wizard.invite_members.copy_link": "Copy Link", + "onboarding_wizard.invite_members.copy_link_input": "team invite link", + "onboarding_wizard.invite_members.description_link": "Collaboration is tough by yourself. Invite a few team members using the invitation link below.", + "onboarding_wizard.invite_members.next_link": "Finish setup", + "onboarding_wizard.invite_members.title": "Invite your team members", "onboarding_wizard.launching_workspace.description": "It’ll be ready in a moment", "onboarding_wizard.launching_workspace.title": "Launching your workspace now", "onboarding_wizard.next": "Continue", - "onboarding_wizard.plugins.description": "Mattermost is better when integrated with the tools your team uses for collaboration. Popular tools are below, select the ones your team uses and we'll add them to your workspace. Additional set up may be needed later.", + "onboarding_wizard.organization.description": "We’ll use this to help personalize your workspace.", + "onboarding_wizard.organization.empty": "You must enter an organization name", + "onboarding_wizard.organization.length": "Organization name must be between {min} and {max} characters", + "onboarding_wizard.organization.other": "Invalid organization name: {reason}", + "onboarding_wizard.organization.placeholder": "Organization name", + "onboarding_wizard.organization.reserved": "Organization name may not start with a reserved word.", + "onboarding_wizard.organization.team_api_error": "There was an error, please try again.", + "onboarding_wizard.organization.title": "What’s the name of your organization?", "onboarding_wizard.plugins.github": "GitHub", "onboarding_wizard.plugins.github.tooltip": "Subscribe to repositories, stay up to date with reviews, assignments", "onboarding_wizard.plugins.gitlab": "GitLab", @@ -4329,13 +4347,14 @@ "onboarding_wizard.plugins.jira": "Jira", "onboarding_wizard.plugins.jira.tooltip": "Create Jira tickets from messages in Mattermost, get notified of important updates in Jira", "onboarding_wizard.plugins.marketplace": "More tools can be added once your workspace is set up. To see all available integrations, visit the Marketplace.", - "onboarding_wizard.plugins.subtitle": "(almost there!)", - "onboarding_wizard.plugins.title": "Welcome to Mattermost!", "onboarding_wizard.plugins.todo": "To do", "onboarding_wizard.plugins.todo.tooltip": "A plugin to track Todo issues in a list and send you daily reminders about your Todo list", "onboarding_wizard.plugins.zoom": "Zoom", "onboarding_wizard.plugins.zoom.tooltip": "Start Zoom audio and video conferencing calls in Mattermost with a single click", - "onboarding_wizard.skip": "Skip for now", + "onboarding_wizard.previous": "Previous", + "onboarding_wizard.self_hosted_plugins.description": "Choose the tools you work with, and we'll add them to your workspace. Additional set up may be needed later.", + "onboarding_wizard.self_hosted_plugins.title": "What tools do you use?", + "onboarding_wizard.skip-button": "Skip", "onboarding_wizard.submit_error.generic": "Something went wrong. Please try again.", "onboardingTask.checklist.completed_subtitle": "We hope Mattermost is more familiar now.", "onboardingTask.checklist.completed_title": "Well done. You’ve completed all of the tasks!", @@ -5787,6 +5806,9 @@ "work_templates.preview.integrations.admin_install.multiple_plugin": "Integrations will not be added until admin installs them.", "work_templates.preview.integrations.admin_install.notify": "Notify admin to install integrations", "work_templates.preview.integrations.admin_install.single_plugin": "{plugin} will not be added until admin installs it.", + "work_templates.preview.integrations.already_installed": "Already installed", + "work_templates.preview.integrations.app_install": "App Install", + "work_templates.preview.integrations.to_be_installed": "To be installed", "work_templates.preview.modal_cancel_button": "Back", "work_templates.preview.modal_next_button": "Next", "work_templates.preview.modal_title": "Preview {useCase}", diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts index f5afb85aa2..f31c122014 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/channels.ts @@ -4,6 +4,18 @@ import {AnyAction} from 'redux'; import {batchActions} from 'redux-batched-actions'; +import {ServerError} from '@mattermost/types/errors'; +import { + Channel, + ChannelNotifyProps, + ChannelMembership, + ChannelModerationPatch, + ChannelsWithTotalCount, + ChannelSearchOpts, + ServerChannel, +} from '@mattermost/types/channels'; +import {PreferenceType} from '@mattermost/types/preferences'; + import {ChannelTypes, PreferenceTypes, UserTypes} from 'mattermost-redux/action_types'; import {Client4} from 'mattermost-redux/client'; @@ -19,18 +31,12 @@ import { getRedirectChannelNameForTeam, isManuallyUnread, } from 'mattermost-redux/selectors/entities/channels'; -import {getConfig, getServerVersion} from 'mattermost-redux/selectors/entities/general'; +import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; import {ActionFunc, ActionResult, DispatchFunc, GetStateFunc} from 'mattermost-redux/types/actions'; -import {getChannelsIdForTeam, getChannelByName} from 'mattermost-redux/utils/channel_utils'; - -import {isMinimumServerVersion} from 'mattermost-redux/utils/helpers'; - -import {Channel, ChannelNotifyProps, ChannelMembership, ChannelModerationPatch, ChannelsWithTotalCount, ChannelSearchOpts} from '@mattermost/types/channels'; - -import {PreferenceType} from '@mattermost/types/preferences'; +import {getChannelByName} from 'mattermost-redux/utils/channel_utils'; import {General, Preferences} from '../constants'; @@ -462,52 +468,33 @@ export function getChannelTimezones(channelId: string): ActionFunc { }; } -export function fetchMyChannelsAndMembersREST(teamId: string): ActionFunc { +export function fetchMyChannelsAndMembersREST(teamId: string): ActionFunc<{channels: ServerChannel[]; channelMembers: ChannelMembership[]}> { return async (dispatch: DispatchFunc, getState: GetStateFunc) => { - dispatch({ - type: ChannelTypes.CHANNELS_REQUEST, - data: null, - }); - let channels; let channelMembers; - const state = getState(); - const shouldFetchArchived = isMinimumServerVersion(getServerVersion(state), 5, 21); try { [channels, channelMembers] = await Promise.all([ - Client4.getMyChannels(teamId, shouldFetchArchived), + Client4.getMyChannels(teamId), Client4.getMyChannelMembers(teamId), ]); } catch (error) { forceLogoutIfNecessary(error, dispatch, getState); - dispatch({type: ChannelTypes.CHANNELS_FAILURE, error}); dispatch(logError(error)); - return {error}; + return {error: error as ServerError}; } - const {currentUserId} = state.entities.users; - const {currentChannelId} = state.entities.channels; - dispatch(batchActions([ { type: ChannelTypes.RECEIVED_CHANNELS, teamId, data: channels, - currentChannelId, - }, - { - type: ChannelTypes.CHANNELS_SUCCESS, }, { type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBERS, data: channelMembers, - sync: !shouldFetchArchived, - channels, - remove: getChannelsIdForTeam(state, teamId), - currentUserId, - currentChannelId, }, ])); + const roles = new Set(); for (const member of channelMembers) { for (const role of member.roles.split(' ')) { @@ -518,7 +505,7 @@ export function fetchMyChannelsAndMembersREST(teamId: string): ActionFunc { dispatch(loadRolesIfNeeded(roles)); } - return {data: {channels, members: channelMembers}}; + return {data: {channels, channelMembers}}; }; } diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/common.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/common.ts index b26a42976c..df49a50724 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/common.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/common.ts @@ -74,7 +74,7 @@ export function getUsers(state: GlobalState): IDMappedObjects { export function getCalls(state: GlobalState): Record { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - return state[CALLS_PLUGIN].voiceConnectedProfiles; + return state[CALLS_PLUGIN].voiceConnectedProfiles || {}; } export function getCallsConfig(state: GlobalState): CallsConfig { diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts index 2ef11c99d3..b2d3c1a672 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts @@ -245,10 +245,6 @@ export function isCustomGroupsEnabled(state: GlobalState): boolean { return getConfig(state).EnableCustomGroups === 'true'; } -export function getUseCaseOnboarding(state: GlobalState): boolean { - return getFeatureFlagValue(state, 'UseCaseOnboarding') === 'true' && getLicense(state)?.Cloud === 'true'; -} - export function insightsAreEnabled(state: GlobalState): boolean { const isConfiguredForFeature = getConfig(state).InsightsEnabled === 'true'; const featureIsEnabled = getFeatureFlagValue(state, 'InsightsEnabled') === 'true'; diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index b63c402fb8..652e102586 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -506,6 +506,11 @@ export const CloudProducts = { LEGACY: 'cloud-legacy', }; +export const CloudBillingTypes = { + INTERNAL: 'internal', + LICENSED: 'licensed', +}; + export const SelfHostedProducts = { STARTER: 'starter', PROFESSIONAL: 'professional', @@ -1103,6 +1108,7 @@ export const DocLinks = { ONBOARD_SSO: 'https://docs.mattermost.com/onboard/sso-saml.html', TRUE_UP_REVIEW: 'https://mattermost.com/pl/true-up-documentation', SELF_HOSTED_BILLING: 'https://docs.mattermost.com/manage/self-hosted-billing.html', + ABOUT_TEAMS: 'https://docs.mattermost.com/welcome/about-teams.html#team-url', }; export const LicenseLinks = { diff --git a/webapp/platform/types/src/cloud.ts b/webapp/platform/types/src/cloud.ts index 74b7195ab2..9cdb28a01c 100644 --- a/webapp/platform/types/src/cloud.ts +++ b/webapp/platform/types/src/cloud.ts @@ -41,6 +41,7 @@ export type Subscription = { is_free_trial: string; delinquent_since?: number; compliance_blocked?: string; + billing_type?: string; } export type Product = { diff --git a/webapp/platform/types/src/config.ts b/webapp/platform/types/src/config.ts index 0a56b79b6a..dfd5e21127 100644 --- a/webapp/platform/types/src/config.ts +++ b/webapp/platform/types/src/config.ts @@ -38,6 +38,7 @@ export type ClientConfig = { DefaultTheme: string; DiagnosticId: string; DiagnosticsEnabled: string; + DisableRefetchingOnBrowserFocus: string; EmailLoginButtonBorderColor: string; EmailLoginButtonColor: string; EmailLoginButtonTextColor: string; @@ -525,7 +526,6 @@ export type EmailSettings = { LoginButtonColor: string; LoginButtonBorderColor: string; LoginButtonTextColor: string; - EnableInactivityEmail: boolean; }; export type RateLimitSettings = { @@ -729,6 +729,7 @@ export type ExperimentalSettings = { EnableRemoteClusterService: boolean; EnableAppBar: boolean; PatchPluginsReactDOM: boolean; + DisableRefetchingOnBrowserFocus: boolean; }; export type AnalyticsSettings = { diff --git a/webapp/platform/types/src/setup.ts b/webapp/platform/types/src/setup.ts index 980aa05dce..085527a434 100644 --- a/webapp/platform/types/src/setup.ts +++ b/webapp/platform/types/src/setup.ts @@ -2,5 +2,6 @@ // See LICENSE.txt for license information. export type CompleteOnboardingRequest = { + organization: string; install_plugins: string[]; }