diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fc307655015..47a8db9d76e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,7 +15,6 @@ jobs: build: name: Build Docker image and Helm Chart runs-on: buildjet-8vcpu-ubuntu-2204 - outputs: wire_builds_target_branches: ${{ steps.output_target_branches.outputs.wire_builds_target_branches }} image_tag: ${{ steps.push_docker_image.outputs.image_tag }} @@ -38,18 +37,15 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 16.x cache: 'yarn' - - name: Set environment variables run: | echo "BRANCH_NAME=$(git branch --show-current)" >> $GITHUB_ENV echo "TAG=$(git tag --points-at ${{github.sha}})" >> $GITHUB_ENV - - name: Print environment variables run: | echo -e "BRANCH_NAME = ${BRANCH_NAME}" @@ -179,7 +175,6 @@ jobs: helm package ./charts/webapp helm s3 push webapp-*.tgz charts-webapp - publish_wire_builds: name: Bump webapp chart in wire-builds runs-on: ubuntu-latest @@ -187,7 +182,6 @@ jobs: strategy: matrix: target_branch: ${{fromJSON(needs.build.outputs.wire_builds_target_branches)}} - steps: - name: Check out wire-builds uses: actions/checkout@v4 @@ -196,7 +190,6 @@ jobs: token: ${{secrets.WIRE_BUILDS_WRITE_ACCESS_GH_TOKEN}} ref: ${{matrix.target_branch}} fetch-depth: 1 - - name: Create new build in wire-build shell: bash run: | @@ -231,14 +224,12 @@ jobs: echo '::set-output name=exists::true' echo "::set-output name=releaseInfo::$(cat ${ARTIFACT_LOCAL_PATH})" fi - - name: Checking out 'wire-server' uses: actions/checkout@v4 if: ${{ steps.release-info-file.outputs.exists == 'true' }} with: repository: 'wireapp/wire-server' fetch-depth: 1 - - name: Changing Helm value of the webapp chart id: change-helm-value if: ${{ steps.release-info-file.outputs.exists == 'true' }} @@ -249,7 +240,6 @@ jobs: echo "Upgrade webapp version to ${{needs.build.outputs.image_tag}}" > ./changelog.d/0-release-notes/webapp-upgrade git add ./changelog.d/0-release-notes/webapp-upgrade echo "::set-output name=releaseUrl::${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/tag/${{needs.build.outputs.release_name}}" - - name: Creating Pull Request id: create-pr if: ${{ steps.release-info-file.outputs.exists == 'true' }} @@ -264,7 +254,6 @@ jobs: body: | Image tag: `${{needs.build.outputs.image_tag}}` Release: [`${{needs.build.outputs.release_name}}`](${{ steps.change-helm-value.outputs.releaseUrl }}) - - name: Printing Pull Request URL if: ${{ steps.release-info-file.outputs.exists == 'true' }} shell: bash diff --git a/.github/workflows/test_build_deploy.yml b/.github/workflows/test_build_deploy.yml new file mode 100644 index 00000000000..392efb54a88 --- /dev/null +++ b/.github/workflows/test_build_deploy.yml @@ -0,0 +1,228 @@ +name: CI + +on: + push: + branches: [master, dev, edge, avs, mobile, acc] + tags: + - '*staging*' + - '*production*' + pull_request: + branches: [master, dev, edge, avs, mobile, acc] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test_build_deploy: + runs-on: buildjet-8vcpu-ubuntu-2204 + + env: + TEST_COVERAGE_FAIL_THRESHOLD: 45 + TEST_COVERAGE_WARNING_THRESHOLD: 60 + DEPLOYMENT_RECOVERY_TIMEOUT_SECONDS: 150 + AWS_APPLICATION_NAME: Webapp + AWS_BUILD_ZIP_PATH: server/dist/s3/ebs.zip + COMMIT_URL: ${{github.event.head_commit.url}} + COMMITTER: ${{github.event.head_commit.committer.name}} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 16.x + cache: 'yarn' + + - name: Set environment variables + run: | + echo "BRANCH_NAME=$(git branch --show-current)" >> $GITHUB_ENV + echo "TAG=$(git tag --points-at ${{github.sha}})" >> $GITHUB_ENV + echo "PR_LAST_COMMIT_MESSAGE=$(git log --format=%B -n 1 ${{github.event.after}} | head -n 1)" >> $GITHUB_ENV + + - name: Set TITLE and BUILD_DESKTOP + env: + PR_TITLE: ${{github.event.pull_request.title || env.PR_LAST_COMMIT_MESSAGE}} + run: | + echo "TITLE=${PR_TITLE}" >> $GITHUB_ENV + echo "BUILD_DESKTOP=${{contains(env.TAG, 'staging') || contains(env.TAG, 'production') || contains(env.PR_LAST_COMMIT_MESSAGE, '+Desktop')}}" >> $GITHUB_ENV + + - name: Print environment variables + run: | + echo -e "BRANCH_NAME = ${BRANCH_NAME}" + echo -e "TAG = ${TAG}" + echo -e "TITLE = ${TITLE}" + echo -e "PR_LAST_COMMIT_MESSAGE = ${PR_LAST_COMMIT_MESSAGE}" + echo -e "COMMIT_URL = ${COMMIT_URL}" + echo -e "COMMITTER = ${COMMITTER}" + echo -e "BUILD_DESKTOP = ${BUILD_DESKTOP}" + + - name: Skip CI + if: | + contains(env.TITLE || env.PR_LAST_COMMIT_MESSAGE, '[skip ci]') || + contains(env.TITLE || env.PR_LAST_COMMIT_MESSAGE, '[ci skip]') + uses: andymckay/cancel-action@0.3 + + - name: Install JS dependencies + run: yarn --immutable + + - name: Test + run: | + set -o pipefail + yarn test --coverage --coverage-reporters=lcov --detectOpenHandles=false 2>&1 | tee ./unit-tests.log + + - name: Monitor coverage + uses: codecov/codecov-action@v3.1.4 + with: + fail_ci_if_error: false + files: ./coverage/lcov.info + flags: unittests + + - uses: kanga333/variable-mapper@master + with: + # We try to map a branch to a dev environment + key: '${{github.ref}}' + map: | + { + "dev": { "dev_env": "wire-webapp-dev-al2" }, + "master": { "dev_env": "wire-webapp-master-al2" } + } + + - uses: kanga333/variable-mapper@master + with: + # We try to map a branch to a dev environment + key: '${{github.ref}}' + map: | + { + "dev": { "preprod_env": "wire-webapp-edge-al2" } + } + + - uses: kanga333/variable-mapper@master + with: + # We try to map a tag to a dev environment + key: '${{env.TAG}}' + map: | + { + "production": { "prod_env": "wire-webapp-prod-al2" }, + "staging": { "prod_env": "wire-webapp-staging-al2" } + } + + - name: Build + if: env.prod_env || env.dev_env + run: yarn build:prod + + # Stage 1: https://wire-webapp-edge.zinfra.io/ + - name: Deploy to dev env + if: env.dev_env + uses: einaregilsson/beanstalk-deploy@v21 + with: + application_name: ${{env.AWS_APPLICATION_NAME}} + aws_access_key: ${{secrets.WEBTEAM_AWS_ACCESS_KEY_ID}} + aws_secret_key: ${{secrets.WEBTEAM_AWS_SECRET_ACCESS_KEY}} + deployment_package: ${{env.AWS_BUILD_ZIP_PATH}} + environment_name: ${{env.dev_env}} + region: eu-central-1 + use_existing_version_if_available: true + version_description: ${{github.sha}} + version_label: ${{github.run_id}} + wait_for_deployment: false + wait_for_environment_recovery: ${{env.DEPLOYMENT_RECOVERY_TIMEOUT_SECONDS}} + + - name: Deploy to pre-prod env + if: env.preprod_env + uses: einaregilsson/beanstalk-deploy@v21 + with: + application_name: ${{env.AWS_APPLICATION_NAME}} + aws_access_key: ${{secrets.WEBTEAM_AWS_ACCESS_KEY_ID}} + aws_secret_key: ${{secrets.WEBTEAM_AWS_SECRET_ACCESS_KEY}} + deployment_package: ${{env.AWS_BUILD_ZIP_PATH}} + environment_name: ${{env.preprod_env}} + region: eu-central-1 + use_existing_version_if_available: true + version_description: ${{github.sha}} + version_label: ${{github.run_id}} + wait_for_deployment: false + wait_for_environment_recovery: ${{env.DEPLOYMENT_RECOVERY_TIMEOUT_SECONDS}} + + - name: Deploy to prod env + if: env.prod_env + uses: einaregilsson/beanstalk-deploy@v21 + with: + application_name: ${{env.AWS_APPLICATION_NAME}} + aws_access_key: ${{secrets.WEBTEAM_AWS_ACCESS_KEY_ID}} + aws_secret_key: ${{secrets.WEBTEAM_AWS_SECRET_ACCESS_KEY}} + deployment_package: ${{env.AWS_BUILD_ZIP_PATH}} + environment_name: ${{env.prod_env}} + region: eu-central-1 + use_existing_version_if_available: true + version_description: ${{github.sha}} + version_label: ${{env.TAG}}-${{github.run_id}} + wait_for_deployment: false + wait_for_environment_recovery: ${{env.DEPLOYMENT_RECOVERY_TIMEOUT_SECONDS}} + + - name: Generate changelog for production release + if: contains(env.TAG, 'production') + run: yarn changelog:production + + - name: Create GitHub production release + id: create_release_production + if: contains(env.TAG, 'production') + uses: softprops/action-gh-release@v1 + env: + GITHUB_TOKEN: ${{github.token}} + with: + tag_name: ${{env.TAG}} + name: ${{env.TAG}} + body_path: ./CHANGELOG.md + files: ./unit-tests.log + draft: false + prerelease: false + + - name: Announce production release + if: contains(env.TAG, 'production') + uses: wireapp/github-action-wire-messenger@v2.0.0 + with: + email: ${{secrets.WIRE_BOT_EMAIL}} + password: ${{secrets.WIRE_BOT_PASSWORD}} + conversation: 1784ed44-7c32-4984-b5e2-0b4d55b034ed + send_text: 'The web team just rolled out a new version of [Wire for Web](https://app.wire.com/). You can find what has changed in our [GitHub release notes](https://github.com/wireapp/wire-webapp/releases/latest).\n\nPlease note that the rollout can take up to 30 minutes to be fully deployed on all nodes. You can check here if you get already served our latest version from today: https://app.wire.com/version' + + - name: Generate changelog for staging release + if: contains(env.TAG, 'staging') + run: yarn changelog:staging + + - name: Create GitHub staging release + id: create_release_staging + if: contains(env.TAG, 'staging') + uses: softprops/action-gh-release@v1 + env: + GITHUB_TOKEN: ${{github.token}} + with: + tag_name: ${{env.TAG}} + name: ${{env.TAG}} + body_path: ./CHANGELOG.md + files: ./unit-tests.log + draft: false + prerelease: true + + - name: Announce staging release + if: contains(env.TAG, 'staging') + uses: wireapp/github-action-wire-messenger@v2.0.0 + with: + email: ${{secrets.WIRE_BOT_EMAIL}} + password: ${{secrets.WIRE_BOT_PASSWORD}} + conversation: '697c93e8-0b13-4204-a35e-59270462366a' + send_text: 'Staging bump for commit **${{github.sha}}** ("${{env.TITLE}}") done! 🏁' + + - name: Notify CI error + if: failure() && github.event_name != 'pull_request' + uses: wireapp/github-action-wire-messenger@v2.0.0 + with: + email: ${{secrets.WIRE_BOT_EMAIL}} + password: ${{secrets.WIRE_BOT_PASSWORD}} + conversation: 'b2cc7120-4154-4be4-b0c0-45a8c361c4d1' + send_text: '${{env.COMMITTER}} broke the "${{env.BRANCH_NAME}}" branch on "${{github.repository}}" with [${{env.TITLE}}](${{env.COMMIT_URL}}) đŸŒ”' diff --git a/server/config/client.config.ts b/server/config/client.config.ts index 562aa68eee6..0bbabe9b16d 100644 --- a/server/config/client.config.ts +++ b/server/config/client.config.ts @@ -96,6 +96,7 @@ export function generateConfig(params: ConfigGeneratorParams, env: Env) { MLS_LEARN_MORE: env.URL_SUPPORT_MLS_LEARN_MORE, PRIVACY_VERIFY_FINGERPRINT: env.URL_SUPPORT_PRIVACY_VERIFY_FINGERPRINT, SCREEN_ACCESS_DENIED: env.URL_SUPPORT_SCREEN_ACCESS_DENIED, + LEARN_MORE_ABOUT_GUEST_LINKS: env.URL_LEARN_MORE_ABOUT_GUEST_LINKS, NON_FEDERATING_INFO: env.URL_SUPPORT_NON_FEDERATING_INFO, OAUTH_LEARN_MORE: env.URL_SUPPORT_OAUTH_LEARN_MORE, OFFLINE_BACKEND: env.URL_SUPPORT_OFFLINE_BACKEND, diff --git a/server/config/env.ts b/server/config/env.ts index c360b5c17ea..6cc48138556 100644 --- a/server/config/env.ts +++ b/server/config/env.ts @@ -248,6 +248,8 @@ export type Env = { URL_SUPPORT_SCREEN_ACCESS_DENIED: string; + URL_LEARN_MORE_ABOUT_GUEST_LINKS: string; + URL_SUPPORT_NON_FEDERATING_INFO: string; URL_SUPPORT_OAUTH_LEARN_MORE: string; diff --git a/src/i18n/en-US.json b/src/i18n/en-US.json index c5ed319938a..8793a5a8d90 100644 --- a/src/i18n/en-US.json +++ b/src/i18n/en-US.json @@ -8,6 +8,7 @@ "BackendError.LABEL.CONVERSATION_CODE_NOT_FOUND": "This link is no longer valid. Ask the person who invited you how to join.", "BackendError.LABEL.CONVERSATION_NOT_FOUND": "CONVERSATION_NOT_FOUND", "BackendError.LABEL.CONVERSATION_TOO_MANY_MEMBERS": "This conversation has reached the limit of participants", + "BackendErrorLabel.INVALID_CONVERSATION_PASSWORD": "The password is incorrect, please try again.", "BackendError.LABEL.EMAIL_EXISTS": "This email address is already in use. {supportEmailExistsLink}", "BackendError.LABEL.EMAIL_REQUIRED": "You can’t use your username as two-factor authentication is activated. Please log in with your email instead.", "BackendError.LABEL.HANDLE_EXISTS": "This username is already taken", @@ -685,8 +686,25 @@ "guestOptionsCreateLink": "Create link", "guestOptionsInfoHeader": "Invite others with a link", "guestOptionsInfoText": "Invite others with a link to this conversation. Anyone with the link can join the conversation, even if they don’t have {{brandName}}.", + "guestOptionsInfoPasswordSecured": "Link is password secured", + "guestOptionsInfoTextWithPassword": "Users are asked to enter the password before they can join the conversation with a guest link.", + "guestOptionsInfoTextForgetPassword": "Forgot password? Revoke the link and create a new one.", + "guestOptionsInfoModalTitle": "Create password secured link", + "guestOptionsInfoModalTitleSubTitle": "People who want to join the conversation via the guest link need to enter this password first.", + "guestOptionsInfoModalCancel": "Cancel", + "guestOptionsInfoModalFormLabel": "Guest link password", + "guestOptionsInfoModalAction": "Create Link", + "guestOptionsInfoModalTitleBoldSubTitle": "You can't change the password later. Make sure to copy and store it.", + "guestOptionsInfoTextSecureWithPassword": "You can also secure the link with a password.", + "guestOptionsPasswordRadioLabel": "Guest link password", + "guestOptionsPasswordRadioOptionSecured": "Password secured", + "guestOptionsPasswordRadioOptionNotSecured": "Not password secured", + "guestOptionsPasswordCopyToClipboard": "Copy Password", + "guestOptionsPasswordCopyToClipboardSuccess": "Password Copied!", + "guestOptionsPasswordForceToCopy": "You need to copy the password so that you can store and share it with people you want to invite.", "guestOptionsRevokeLink": "Revoke link", "guestOptionsTitle": "Guests", + "generatePassword": "Generate password", "guestRoomConversationBadge": "[bold]Guests[/bold] are present", "guestRoomConversationBadgeExternal": "[bold]Externals[/bold] are present", "guestRoomConversationBadgeExternalAndGuest": "[bold]Externals[/bold] and [bold]guests[/bold] are present", @@ -709,6 +727,15 @@ "guestRoomToggleInfoDisabled": "You can't disable the guest option in this conversation, as it has been created by someone from another team.", "guestRoomToggleInfoExtended": "Open this conversation to people outside your team. You can always change it later.", "guestRoomToggleInfoHead": "Guest Links", + "guestLinkPasswordModal.headline": "{conversationName} \n Enter password", + "guestLinkPasswordModal.headlineDefault": "Group Conversation \n Enter password", + "guestLinkPasswordModal.description": "Please enter the password you have received with the access link for this conversation.", + "guestLinkPasswordModal.conversationPasswordProtected": "This conversation is password protected.", + "guestLinkPasswordModal.passwordInputLabel": "Conversation password", + "guestLinkPasswordModal.passwordInputPlaceholder": "Enter Conversation password", + "guestLinkPasswordModal.learnMoreLink": "Learn more about guest links", + "guestLinkPasswordModal.joinConversation": "Join Conversation", + "guestLinkPasswordModal.passwordIncorrect": "Password is incorrect, please try again.", "guestRoomToggleName": "Allow Guests", "historyInfo.learnMore": "Learn more", "historyInfo.noHistoryHeadline": "It’s the first time you’re using {brandName} on this device.", @@ -929,6 +956,11 @@ "modalConversationGuestOptionsGetCodeMessage": "Could not get access link.", "modalConversationGuestOptionsRequestCodeMessage": "Could not request access link. Please try again.", "modalConversationGuestOptionsRevokeCodeMessage": "Could not revoke access link. Please try again.", + "modalGuestLinkJoinPlaceholder": "Enter password", + "modalGuestLinkJoinConfirmPlaceholder": "Confirm your password", + "modalGuestLinkJoinLabel": "Set password", + "modalGuestLinkJoinConfirmLabel": "Confirm password", + "modalGuestLinkJoinHelperText": "Use at least {{minPasswordLength}} characters, with one lowercase letter, one capital letter, a number, and a special character.", "modalConversationJoinConfirm": "Join", "modalConversationJoinFullHeadline": "You could not join the conversation", "modalConversationJoinFullMessage": "The conversation is full.", diff --git a/src/script/auth/component/JoinGuestLinkPasswordModal.test.tsx b/src/script/auth/component/JoinGuestLinkPasswordModal.test.tsx new file mode 100644 index 00000000000..66c09b6d6e4 --- /dev/null +++ b/src/script/auth/component/JoinGuestLinkPasswordModal.test.tsx @@ -0,0 +1,68 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {fireEvent, render} from '@testing-library/react'; + +import {JoinGuestLinkPasswordModal, JoinGuestLinkPasswordModalProps} from './JoinGuestLinkPasswordModal'; + +import {withIntl, withTheme} from '../util/test/TestUtil'; + +describe('JoinGuestLinkPasswordModal', () => { + const onSubmitPasswordMock = jest.fn(); + const props: JoinGuestLinkPasswordModalProps = { + onSubmitPassword: onSubmitPasswordMock, + onClose: jest.fn(), + conversationName: 'test group', + error: null, + }; + + beforeEach(() => { + onSubmitPasswordMock.mockClear(); + }); + + it('should call onSubmitPassword with the password value when the form is submitted', () => { + const {getByTestId} = render(withTheme(withIntl())); + const input = getByTestId('guest-link-join-password-input') as HTMLInputElement; + const joinConversationButton = getByTestId('guest-link-join-submit-button') as HTMLButtonElement; + fireEvent.change(input, {target: {value: 'password'}}); + joinConversationButton.click(); + expect(onSubmitPasswordMock).toHaveBeenCalledWith('password'); + }); + + it('should disable the join conversation button when the password input is empty', () => { + const {getByTestId} = render(withTheme(withIntl())); + const joinConversationButton = getByTestId('guest-link-join-submit-button') as HTMLButtonElement; + expect(joinConversationButton.disabled).toBe(true); + }); + + it('should enable the join conversation button when the password input is not empty', () => { + const {getByTestId} = render(withTheme(withIntl())); + const input = getByTestId('guest-link-join-password-input'); + const joinConversationButton = getByTestId('guest-link-join-submit-button') as HTMLButtonElement; + fireEvent.change(input, {target: {value: 'password'}}); + expect(joinConversationButton.disabled).toBe(false); + }); + + it('should not call onSubmitPassword with an empty string when the form is submitted with an empty password input', () => { + const {getByTestId} = render(withTheme(withIntl())); + const joinConversationButton = getByTestId('guest-link-join-submit-button') as HTMLButtonElement; + joinConversationButton.click(); + expect(onSubmitPasswordMock).toHaveBeenCalledTimes(0); + }); +}); diff --git a/src/script/auth/component/JoinGuestLinkPasswordModal.tsx b/src/script/auth/component/JoinGuestLinkPasswordModal.tsx new file mode 100644 index 00000000000..92418e0c90c --- /dev/null +++ b/src/script/auth/component/JoinGuestLinkPasswordModal.tsx @@ -0,0 +1,112 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import React, {useState} from 'react'; + +import {StatusCodes as HTTP_STATUS} from 'http-status-codes'; +import {useIntl} from 'react-intl'; + +import {Button, COLOR, Container, ErrorMessage, Form, H2, Input, Link, Modal, Text} from '@wireapp/react-ui-kit'; + +import {Config} from '../../Config'; +import {joinGuestLinkPasswordModalStrings} from '../../strings'; + +export interface JoinGuestLinkPasswordModalProps { + onSubmitPassword: (password: string) => void; + isLoading?: boolean; + conversationName?: string; + error: (Error & {label?: string; code?: number; message?: string}) | null; + onClose: () => void; +} + +const JoinGuestLinkPasswordModal: React.FC = ({ + error, + onClose, + isLoading, + conversationName, + onSubmitPassword, +}) => { + const {formatMessage: _} = useIntl(); + const [passwordValue, setPasswordValue] = useState(''); + + const onSubmit = (event: React.FormEvent) => { + event.preventDefault(); + onSubmitPassword(passwordValue); + }; + + const Error = () => { + if (error?.code === HTTP_STATUS.FORBIDDEN || error?.code === HTTP_STATUS.BAD_REQUEST) { + return {_(joinGuestLinkPasswordModalStrings.passwordIncorrect)}; + } + return null; + }; + + return ( + + +

+ {conversationName + ? _(joinGuestLinkPasswordModalStrings.headline, {conversationName}) + : _(joinGuestLinkPasswordModalStrings.headlineDefault)} +

+ + {_(joinGuestLinkPasswordModalStrings.description)} + +
) => onSubmit(event)} + autoComplete="off" + > + } + data-uie-name="guest-link-join-password-input" + name="guest-join-password" + required + placeholder={_(joinGuestLinkPasswordModalStrings.passwordInputLabel)} + label={_(joinGuestLinkPasswordModalStrings.passwordInputLabel)} + id="guest_link_join_password" + className="modal__input" + type="password" + autoComplete="off" + value={passwordValue} + onChange={event => setPasswordValue(event.currentTarget.value)} + /> +
+ + + {_(joinGuestLinkPasswordModalStrings.learnMoreLink)} + + + +
+
+ ); +}; + +export {JoinGuestLinkPasswordModal}; diff --git a/src/script/auth/module/action/AuthAction.ts b/src/script/auth/module/action/AuthAction.ts index 7cdf1a3d006..75c3ce1c474 100644 --- a/src/script/auth/module/action/AuthAction.ts +++ b/src/script/auth/module/action/AuthAction.ts @@ -53,6 +53,7 @@ export class AuthAction { code: string, uri?: string, getEntropy?: () => Promise, + password?: string, ): ThunkAction => { const onBeforeLogin: LoginLifecycleFunction = async (dispatch, getState, {actions: {authAction}}) => dispatch(authAction.doSilentLogout()); @@ -61,7 +62,7 @@ export class AuthAction { getState, {actions: {localStorageAction, conversationAction}}, ) => { - const conversation = await dispatch(conversationAction.doJoinConversationByCode(key, code, uri)); + const conversation = await dispatch(conversationAction.doJoinConversationByCode(key, code, uri, password)); const domain = conversation?.qualified_conversation?.domain; return ( conversation && diff --git a/src/script/auth/module/action/ConversationAction.ts b/src/script/auth/module/action/ConversationAction.ts index 938ced3dfee..1a6b5cf1adc 100644 --- a/src/script/auth/module/action/ConversationAction.ts +++ b/src/script/auth/module/action/ConversationAction.ts @@ -17,7 +17,11 @@ * */ +import type {ConversationJoinData} from '@wireapp/api-client/lib/conversation/data/ConversationJoinData'; import type {ConversationEvent} from '@wireapp/api-client/lib/event/'; +import {StatusCodes as HTTP_STATUS} from 'http-status-codes'; + +import {isBackendError} from 'Util/TypePredicateUtil'; import {ConversationActionCreator} from './creator/'; @@ -37,18 +41,54 @@ export class ConversationAction { }; }; - doJoinConversationByCode = (key: string, code: string, uri?: string): ThunkAction> => { + doJoinConversationByCode = ( + key: string, + code: string, + uri?: string, + password?: string, + ): ThunkAction> => { return async (dispatch, getState, {apiClient}) => { dispatch(ConversationActionCreator.startJoinConversationByCode()); try { - const conversationEvent = await apiClient.api.conversation.postJoinByCode({code, key, uri}); + const conversationEvent = await apiClient.api.conversation.postJoinByCode({code, key, uri, password}); dispatch(ConversationActionCreator.successfulJoinConversationByCode(conversationEvent)); return conversationEvent; } catch (error) { + /* + Backend does return a password-invalid error even though we have not submitted any password + expected: passsword-required. + received: password-invalid. + we have to dispatch conversation info with has_password field in order to handle error message in JoinGuestLinkPasswordModal properly. + */ + if (isBackendError(error) && !password && error.code === HTTP_STATUS.FORBIDDEN) { + dispatch( + ConversationActionCreator.successfulConversationCodeGetInfo({ + has_password: true, + } as ConversationJoinData), + ); + throw error; + } dispatch(ConversationActionCreator.failedJoinConversationByCode(error)); throw error; } }; }; + + doGetConversationInfoByCode = (key: string, code: string): ThunkAction> => { + return async (dispatch, getState, {apiClient}) => { + dispatch(ConversationActionCreator.startConversationCodeGetInfo()); + try { + const conversationInfo = await apiClient.api.conversation.getJoinByCode({code, key}); + dispatch(ConversationActionCreator.successfulConversationCodeGetInfo(conversationInfo)); + return conversationInfo; + } catch (error) { + if (isBackendError(error)) { + dispatch(ConversationActionCreator.failedConversationCodeGetInfo(error)); + return undefined; + } + throw error; + } + }; + }; } export const conversationAction = new ConversationAction(); diff --git a/src/script/auth/module/action/creator/ConversationActionCreator.ts b/src/script/auth/module/action/creator/ConversationActionCreator.ts index 723fc55ac7b..70b1b9b53e6 100644 --- a/src/script/auth/module/action/creator/ConversationActionCreator.ts +++ b/src/script/auth/module/action/creator/ConversationActionCreator.ts @@ -17,7 +17,9 @@ * */ +import type {ConversationJoinData} from '@wireapp/api-client/lib/conversation/data/ConversationJoinData'; import type {ConversationEvent} from '@wireapp/api-client/lib/event/'; +import type {BackendError} from '@wireapp/api-client/lib/http/'; import type {AppAction} from '.'; @@ -25,6 +27,9 @@ export enum CONVERSATION_ACTION { CONVERSATION_CODE_CHECK_FAILED = 'CONVERSATION_CODE_CHECK_FAILED', CONVERSATION_CODE_CHECK_START = 'CONVERSATION_CODE_CHECK_START', CONVERSATION_CODE_CHECK_SUCCESS = 'CONVERSATION_CODE_CHECK_SUCCESS', + CONVERSATION_CODE_GET_INFO_FAILED = 'CONVERSATION_CODE_GET_INFO_FAILED', + CONVERSATION_CODE_GET_INFO_START = 'CONVERSATION_CODE_GET_INFO_START', + CONVERSATION_CODE_GET_INFO_SUCCESS = 'CONVERSATION_CODE_GET_INFO_SUCCESS', CONVERSATION_CODE_JOIN_FAILED = 'CONVERSATION_CODE_JOIN_FAILED', CONVERSATION_CODE_JOIN_START = 'CONVERSATION_CODE_JOIN_START', CONVERSATION_CODE_JOIN_SUCCESS = 'CONVERSATION_CODE_JOIN_SUCCESS', @@ -34,6 +39,9 @@ export type ConversationActions = | ConversationCodeCheckStartAction | ConversationCodeCheckSuccessAction | ConversationCodeCheckFailedAction + | ConversationCodeGetInfoStartAction + | ConversationCodeGetInfoSuccessAction + | ConversationCodeGetInfoFailedAction | ConversationCodeJoinStartAction | ConversationCodeJoinSuccessAction | ConversationCodeJoinFailedAction; @@ -49,6 +57,18 @@ export interface ConversationCodeCheckFailedAction extends AppAction { readonly type: CONVERSATION_ACTION.CONVERSATION_CODE_CHECK_FAILED; } +export interface ConversationCodeGetInfoStartAction extends AppAction { + readonly type: CONVERSATION_ACTION.CONVERSATION_CODE_GET_INFO_START; +} +export interface ConversationCodeGetInfoSuccessAction extends AppAction { + readonly payload: ConversationJoinData; + readonly type: CONVERSATION_ACTION.CONVERSATION_CODE_GET_INFO_SUCCESS; +} +export interface ConversationCodeGetInfoFailedAction extends AppAction { + readonly error: BackendError; + readonly type: CONVERSATION_ACTION.CONVERSATION_CODE_GET_INFO_FAILED; +} + export interface ConversationCodeJoinStartAction extends AppAction { readonly type: CONVERSATION_ACTION.CONVERSATION_CODE_JOIN_START; } @@ -57,7 +77,7 @@ export interface ConversationCodeJoinSuccessAction extends AppAction { readonly type: CONVERSATION_ACTION.CONVERSATION_CODE_JOIN_SUCCESS; } export interface ConversationCodeJoinFailedAction extends AppAction { - readonly error: Error; + readonly error: Error | null; readonly type: CONVERSATION_ACTION.CONVERSATION_CODE_JOIN_FAILED; } @@ -73,6 +93,18 @@ export class ConversationActionCreator { type: CONVERSATION_ACTION.CONVERSATION_CODE_CHECK_FAILED, }); + static startConversationCodeGetInfo = (): ConversationCodeGetInfoStartAction => ({ + type: CONVERSATION_ACTION.CONVERSATION_CODE_GET_INFO_START, + }); + static successfulConversationCodeGetInfo = (data: ConversationJoinData): ConversationCodeGetInfoSuccessAction => ({ + payload: data, + type: CONVERSATION_ACTION.CONVERSATION_CODE_GET_INFO_SUCCESS, + }); + static failedConversationCodeGetInfo = (error: BackendError): ConversationCodeGetInfoFailedAction => ({ + error, + type: CONVERSATION_ACTION.CONVERSATION_CODE_GET_INFO_FAILED, + }); + static startJoinConversationByCode = (): ConversationCodeJoinStartAction => ({ type: CONVERSATION_ACTION.CONVERSATION_CODE_JOIN_START, }); @@ -80,7 +112,7 @@ export class ConversationActionCreator { payload: data, type: CONVERSATION_ACTION.CONVERSATION_CODE_JOIN_SUCCESS, }); - static failedJoinConversationByCode = (error: Error): ConversationCodeJoinFailedAction => ({ + static failedJoinConversationByCode = (error: Error | null): ConversationCodeJoinFailedAction => ({ error, type: CONVERSATION_ACTION.CONVERSATION_CODE_JOIN_FAILED, }); diff --git a/src/script/auth/module/reducer/authReducer.ts b/src/script/auth/module/reducer/authReducer.ts index 82746f58c6c..2bcf2e94f5f 100644 --- a/src/script/auth/module/reducer/authReducer.ts +++ b/src/script/auth/module/reducer/authReducer.ts @@ -98,6 +98,7 @@ export function authReducer(state: AuthState = initialAuthState, action: AppActi switch (action.type) { case AUTH_ACTION.LOGIN_START: case AUTH_ACTION.REGISTER_JOIN_START: + case AUTH_ACTION.REGISTER_WIRELESS_START: case AUTH_ACTION.REGISTER_PERSONAL_START: case AUTH_ACTION.REGISTER_TEAM_START: case USER_ACTION.USER_SEND_ACTIVATION_CODE_START: { @@ -146,6 +147,7 @@ export function authReducer(state: AuthState = initialAuthState, action: AppActi case AUTH_ACTION.LOGIN_FAILED: case AUTH_ACTION.REGISTER_JOIN_FAILED: case AUTH_ACTION.REGISTER_PERSONAL_FAILED: + case AUTH_ACTION.REGISTER_WIRELESS_FAILED: case AUTH_ACTION.REGISTER_TEAM_FAILED: case USER_ACTION.USER_SEND_ACTIVATION_CODE_FAILED: { return { @@ -166,6 +168,7 @@ export function authReducer(state: AuthState = initialAuthState, action: AppActi case AUTH_ACTION.LOGIN_SUCCESS: case AUTH_ACTION.REFRESH_SUCCESS: case AUTH_ACTION.REGISTER_JOIN_SUCCESS: + case AUTH_ACTION.REGISTER_WIRELESS_SUCCESS: case AUTH_ACTION.REGISTER_PERSONAL_SUCCESS: case AUTH_ACTION.REGISTER_TEAM_SUCCESS: { return { diff --git a/src/script/auth/module/reducer/conversationReducer.ts b/src/script/auth/module/reducer/conversationReducer.ts index 25849e1b6bf..3d83caf2e44 100644 --- a/src/script/auth/module/reducer/conversationReducer.ts +++ b/src/script/auth/module/reducer/conversationReducer.ts @@ -17,18 +17,27 @@ * */ +import type {ConversationJoinData} from '@wireapp/api-client/lib/conversation/data/ConversationJoinData'; +import type {BackendError} from '@wireapp/api-client/lib/http/'; + import {AppActions, CONVERSATION_ACTION} from '../action/creator/'; export interface ConversationState { - error: Error & {label?: string}; + error: (Error & {label?: string; code?: number; message?: string}) | null; fetched: boolean; fetching: boolean; + conversationInfoFetching: boolean; + conversationInfoError: BackendError | null; + conversationInfo: ConversationJoinData | null; } export const initialConversationState: ConversationState = { error: null, fetched: false, fetching: false, + conversationInfoFetching: false, + conversationInfoError: null, + conversationInfo: null, }; export function conversationReducer( @@ -59,6 +68,29 @@ export function conversationReducer( fetching: false, }; } + case CONVERSATION_ACTION.CONVERSATION_CODE_GET_INFO_START: { + return { + ...state, + conversationInfoFetching: true, + conversationInfoError: null, + }; + } + case CONVERSATION_ACTION.CONVERSATION_CODE_GET_INFO_FAILED: { + return { + ...state, + conversationInfoFetching: false, + conversationInfoError: null, + }; + } + case CONVERSATION_ACTION.CONVERSATION_CODE_GET_INFO_SUCCESS: { + return { + ...state, + fetching: false, + conversationInfoFetching: false, + conversationInfoError: null, + conversationInfo: {...state.conversationInfo, ...action.payload}, + }; + } default: { return state; } diff --git a/src/script/auth/module/selector/ConversationSelector.ts b/src/script/auth/module/selector/ConversationSelector.ts index 196f3358733..4b4a0e9121a 100644 --- a/src/script/auth/module/selector/ConversationSelector.ts +++ b/src/script/auth/module/selector/ConversationSelector.ts @@ -21,3 +21,6 @@ import type {RootState} from '../reducer'; export const isFetching = (state: RootState) => state.conversationState.fetching; export const getError = (state: RootState) => state.conversationState.error; +export const conversationInfo = (state: RootState) => state.conversationState.conversationInfo; +export const conversationInfoError = (state: RootState) => state.conversationState.conversationInfoError; +export const conversationInfoFetching = (state: RootState) => state.conversationState.conversationInfoFetching; diff --git a/src/script/auth/page/ConversationJoin.tsx b/src/script/auth/page/ConversationJoin.tsx index 95effb92d78..c43f60b8f77 100644 --- a/src/script/auth/page/ConversationJoin.tsx +++ b/src/script/auth/page/ConversationJoin.tsx @@ -39,6 +39,7 @@ import {Page} from './Page'; import {Config} from '../../Config'; import {conversationJoinStrings} from '../../strings'; import {AppAlreadyOpen} from '../component/AppAlreadyOpen'; +import {JoinGuestLinkPasswordModal} from '../component/JoinGuestLinkPasswordModal'; import {UnsupportedBrowser} from '../component/UnsupportedBrowser'; import {WirelessContainer} from '../component/WirelessContainer'; import {EXTERNAL_ROUTE} from '../externalRoute'; @@ -46,6 +47,7 @@ import {actionRoot as ROOT_ACTIONS} from '../module/action'; import {ValidationError} from '../module/action/ValidationError'; import {bindActionCreators, RootState} from '../module/reducer'; import * as AuthSelector from '../module/selector/AuthSelector'; +import * as ClientSelector from '../module/selector/ClientSelector'; import * as ConversationSelector from '../module/selector/ConversationSelector'; import * as SelfSelector from '../module/selector/SelfSelector'; import {QUERY_KEY} from '../route'; @@ -60,13 +62,24 @@ const ConversationJoinComponent = ({ doRegisterWireless, setLastEventDate, doLogout, + doGetConversationInfoByCode, selfName, conversationError, + hasLoadedClients, + isFetchingAuth, + isFetchingConversation, + conversationInfo, + conversationInfoFetching, + generalError, + doGetAllClients, }: Props & ConnectedProps & DispatchProps) => { const nameInput = React.useRef(null); const {formatMessage: _} = useIntl(); + const conversationHasPassword = conversationInfo?.has_password; + const [accentColor] = useState(AccentColor.STRONG_BLUE); + const [isJoinGuestLinkPasswordModalOpen, setIsJoinGuestLinkPasswordModalOpen] = useState(false); const [conversationCode, setConversationCode] = useState(); const [conversationKey, setConversationKey] = useState(); const [enteredName, setEnteredName] = useState(''); @@ -77,7 +90,11 @@ const ConversationJoinComponent = ({ const [isSubmitingName, setIsSubmitingName] = useState(false); const [showCookiePolicyBanner, setShowCookiePolicyBanner] = useState(true); const [showEntropyForm, setShowEntropyForm] = useState(false); + const [isTemporaryGuest, setIsTemporaryGuest] = useState(false); const isEntropyRequired = Config.getConfig().FEATURE.ENABLE_EXTRA_CLIENT_ENTROPY; + + const isFetching = isFetchingAuth || isFetchingConversation || conversationInfoFetching; + const isWirePublicInstance = Config.getConfig().BRAND_NAME === 'Wire'; useEffect(() => { @@ -91,12 +108,17 @@ const ConversationJoinComponent = ({ setIsValidLink(true); doInit({isImmediateLogin: false, shouldValidateLocalClient: true}) .catch(noop) - .then(() => - localConversationCode && localConversationKey - ? doCheckConversationCode(localConversationKey, localConversationCode) - : null, - ) + .then(async () => { + if (localConversationCode && localConversationKey) { + await doCheckConversationCode(localConversationKey, localConversationCode); + await doGetConversationInfoByCode(localConversationKey, localConversationCode); + } + await doGetAllClients(); + }) .catch(error => { + if (error.label === BackendErrorLabel.INVALID_CREDENTIALS) { + return; + } setIsValidLink(false); }); }, []); @@ -108,26 +130,39 @@ const ConversationJoinComponent = ({ window.location.replace(redirectLocation); }; - const getConversationInfoAndJoin = async () => { + const getConversationInfoAndJoin = async (password?: string) => { + if (!isJoinGuestLinkPasswordModalOpen && !!conversationHasPassword) { + setIsJoinGuestLinkPasswordModalOpen(true); + return; + } try { if (!conversationCode || !conversationKey) { throw Error('Conversation code or key missing'); } - const conversationEvent = await doJoinConversationByCode(conversationKey, conversationCode); + const conversationEvent = await doJoinConversationByCode(conversationKey, conversationCode, undefined, password); /* When we join a conversation, we create the join event before loading the webapp. * That means that when the webapp loads and tries to fetch the notificationStream is will get the join event once again and will try to handle it * Here we set the core's lastEventDate so that it knows that this duplicated event should be skipped */ - await setLastEventDate(new Date(conversationEvent.time)); + await setLastEventDate(conversationEvent?.time ? new Date(conversationEvent.time) : new Date()); - routeToApp(conversationEvent.conversation, conversationEvent.qualified_conversation?.domain ?? ''); + routeToApp(conversationEvent?.conversation, conversationEvent?.qualified_conversation?.domain ?? ''); } catch (error) { + setIsSubmitingName(false); + if (error.label === BackendErrorLabel.INVALID_CONVERSATION_PASSWORD) { + setIsJoinGuestLinkPasswordModalOpen(true); + return; + } console.warn('Unable to join conversation', error); setShowEntropyForm(false); } }; - const handleSubmit = async (entropyData?: Uint8Array) => { + const handleSubmit = async (entropyData?: Uint8Array, password?: string) => { + if (!isJoinGuestLinkPasswordModalOpen && !!conversationHasPassword) { + setIsJoinGuestLinkPasswordModalOpen(true); + return; + } setIsSubmitingName(true); try { if (!conversationCode || !conversationKey) { @@ -146,7 +181,7 @@ const ConversationJoinComponent = ({ }, entropyData, ); - await getConversationInfoAndJoin(); + await getConversationInfoAndJoin(password); } catch (error) { setIsSubmitingName(false); if (error.label) { @@ -156,7 +191,7 @@ const ConversationJoinComponent = ({ error.label.endsWith(errorType), ); if (!isValidationError) { - doLogout(); + void doLogout(); console.warn('Unable to create wireless account', error); setShowEntropyForm(false); } @@ -175,16 +210,17 @@ const ConversationJoinComponent = ({ const checkNameValidity = async (event: React.FormEvent) => { event.preventDefault(); - if (nameInput.current) { - nameInput.current.value = nameInput.current.value.trim(); - if (!nameInput.current.checkValidity()) { - setError(ValidationError.handleValidationState('name', nameInput.current.validity)); - setIsValidName(false); - } else if (isEntropyRequired) { - setShowEntropyForm(true); - } else { - await handleSubmit(); - } + if (!nameInput.current) { + return; + } + nameInput.current.value = nameInput.current.value.trim(); + if (!nameInput.current.checkValidity()) { + setError(ValidationError.handleValidationState('name', nameInput.current.validity)); + setIsValidName(false); + } else if (isEntropyRequired) { + setShowEntropyForm(true); + } else { + await handleSubmit(); } }; @@ -204,12 +240,29 @@ const ConversationJoinComponent = ({ const isFullConversation = conversationError && conversationError.label && conversationError.label === BackendErrorLabel.TOO_MANY_MEMBERS; + + const submitJoinCodeWithPassword = async (password: string) => { + await handleSubmit(undefined, password); + }; + if (isFullConversation) { return ; } return ( + {isJoinGuestLinkPasswordModalOpen && ( + { + setIsJoinGuestLinkPasswordModalOpen(false); + setIsTemporaryGuest(false); + }} + error={conversationError || generalError} + isLoading={isFetching} + conversationName={conversationInfo?.name} + onSubmitPassword={!isTemporaryGuest ? getConversationInfoAndJoin : submitJoinCodeWithPassword} + /> + )} setShowCookiePolicyBanner(false)} @@ -227,7 +280,7 @@ const ConversationJoinComponent = ({ - {selfName ? ( + {selfName && hasLoadedClients ? ( ) : ( @@ -244,7 +297,10 @@ const ConversationJoinComponent = ({ nameInput={nameInput} onNameChange={onNameChange} checkNameValidity={checkNameValidity} - handleSubmit={handleSubmit} + handleSubmit={async () => { + setIsTemporaryGuest(true); + await handleSubmit(); + }} isSubmitingName={isSubmitingName} isValidName={isValidName} conversationError={conversationError} @@ -261,18 +317,25 @@ const ConversationJoinComponent = ({ type ConnectedProps = ReturnType; const mapStateToProps = (state: RootState) => ({ - conversationError: ConversationSelector.getError(state), + isFetchingAuth: AuthSelector.isFetching(state), isAuthenticated: AuthSelector.isAuthenticated(state), - isFetching: ConversationSelector.isFetching(state), + hasLoadedClients: ClientSelector.hasLoadedClients(state), + isFetchingConversation: ConversationSelector.isFetching(state), isTemporaryGuest: SelfSelector.isTemporaryGuest(state), selfName: !SelfSelector.isTemporaryGuest(state) && SelfSelector.getSelfName(state), + conversationError: ConversationSelector.getError(state), + conversationInfo: ConversationSelector.conversationInfo(state), + conversationInfoFetching: ConversationSelector.conversationInfoFetching(state), + generalError: AuthSelector.getError(state), }); type DispatchProps = ReturnType; const mapDispatchToProps = (dispatch: Dispatch) => bindActionCreators( { + doGetAllClients: ROOT_ACTIONS.clientAction.doGetAllClients, doCheckConversationCode: ROOT_ACTIONS.conversationAction.doCheckConversationCode, + doGetConversationInfoByCode: ROOT_ACTIONS.conversationAction.doGetConversationInfoByCode, doInit: ROOT_ACTIONS.authAction.doInit, doJoinConversationByCode: ROOT_ACTIONS.conversationAction.doJoinConversationByCode, doLogout: ROOT_ACTIONS.authAction.doLogout, diff --git a/src/script/auth/page/ConversationJoinComponents.tsx b/src/script/auth/page/ConversationJoinComponents.tsx index 4a344e66646..70ae3e47c1d 100644 --- a/src/script/auth/page/ConversationJoinComponents.tsx +++ b/src/script/auth/page/ConversationJoinComponents.tsx @@ -56,7 +56,7 @@ interface GuestLoginColumnProps { isValidName: boolean; isSubmitingName: boolean; nameInput: React.RefObject; - conversationError: Error & {label?: string | undefined}; + conversationError: (Error & {label?: string | undefined}) | null; error: any; } diff --git a/src/script/auth/page/Login.tsx b/src/script/auth/page/Login.tsx index 8ad47316ecd..ffb48c55634 100644 --- a/src/script/auth/page/Login.tsx +++ b/src/script/auth/page/Login.tsx @@ -64,6 +64,7 @@ import {Page} from './Page'; import {Config} from '../../Config'; import {loginStrings, verifyStrings} from '../../strings'; import {AppAlreadyOpen} from '../component/AppAlreadyOpen'; +import {JoinGuestLinkPasswordModal} from '../component/JoinGuestLinkPasswordModal'; import {LoginForm} from '../component/LoginForm'; import {RouterLink} from '../component/RouterLink'; import {EXTERNAL_ROUTE} from '../externalRoute'; @@ -72,6 +73,7 @@ import {LabeledError} from '../module/action/LabeledError'; import {ValidationError} from '../module/action/ValidationError'; import {bindActionCreators, RootState} from '../module/reducer'; import * as AuthSelector from '../module/selector/AuthSelector'; +import * as ConversationSelector from '../module/selector/ConversationSelector'; import {QUERY_KEY, ROUTE} from '../route'; import {parseError, parseValidationErrors} from '../util/errorUtil'; import {getOAuthQueryString} from '../util/oauthUtil'; @@ -84,11 +86,13 @@ const LoginComponent = ({ authError, resetAuthError, doCheckConversationCode, + doGetConversationInfoByCode, doInit, doSetLocalStorage, doInitializeClient, doLoginAndJoin, doLogin, + conversationError, pushEntropyData, doSendTwoFactorCode, isFetching, @@ -96,6 +100,8 @@ const LoginComponent = ({ loginData, defaultSSOCode, isSendingTwoFactorCode, + conversationInfo, + conversationInfoFetching, embedded, }: Props & ConnectedProps & DispatchProps) => { const logger = getLogger('Login'); @@ -103,7 +109,8 @@ const LoginComponent = ({ const navigate = useNavigate(); const [conversationCode, setConversationCode] = useState(null); const [conversationKey, setConversationKey] = useState(null); - + const [conversationSubmitData, setConversationSubmitData] = useState | null>(null); + const [isLinkPasswordModalOpen, setIsLinkPasswordModalOpen] = useState(false); const [isValidLink, setIsValidLink] = useState(true); const [validationErrors, setValidationErrors] = useState([]); @@ -118,6 +125,7 @@ const LoginComponent = ({ const isEntropyRequired = Config.getConfig().FEATURE.ENABLE_EXTRA_CLIENT_ENTROPY; const onEntropyGenerated = useRef<((entropy: Uint8Array) => void) | undefined>(); const entropy = useRef(); + const getEntropy = isEntropyRequired ? () => { // This is somewhat hacky. When the login action detects a new device and that entropy is required, then we give back a promise to the login action. @@ -160,6 +168,10 @@ const LoginComponent = ({ logger.warn('Invalid conversation code', error); setIsValidLink(false); }); + doGetConversationInfoByCode(queryConversationKey, queryConversationCode).catch(error => { + logger.warn('Failed to fetch conversation info', error); + setIsValidLink(false); + }); } }, []); @@ -193,8 +205,23 @@ const LoginComponent = ({ } }; - const handleSubmit = async (formLoginData: Partial, validationErrors: Error[] = []) => { + const handleSubmit = async ( + formLoginData: Partial, + validationErrors: Error[] = [], + conversationPassword?: string, + ) => { setValidationErrors(validationErrors); + + if ( + !isLinkPasswordModalOpen && + (!!conversationInfo?.has_password || + (!!conversationError && conversationError.label === BackendErrorLabel.INVALID_CONVERSATION_PASSWORD)) + ) { + setConversationSubmitData(formLoginData); + setIsLinkPasswordModalOpen(true); + return; + } + try { const login: LoginData = {...formLoginData, clientType: loginData.clientType}; if (validationErrors.length) { @@ -203,9 +230,19 @@ const LoginComponent = ({ const hasKeyAndCode = conversationKey && conversationCode; if (hasKeyAndCode) { - await doLoginAndJoin(login, conversationKey, conversationCode, undefined, getEntropy); + try { + await doLoginAndJoin(login, conversationKey, conversationCode, undefined, getEntropy, conversationPassword); + } catch (error) { + if (isBackendError(error) && error.label === BackendErrorLabel.INVALID_CONVERSATION_PASSWORD) { + setConversationSubmitData(formLoginData); + setIsLinkPasswordModalOpen(true); + return; + } + throw error; + } + } else { + await doLogin(login, getEntropy); } - await doLogin(login, getEntropy); if (isOauth) { const queryString = getOAuthQueryString(window.location); @@ -216,6 +253,11 @@ const LoginComponent = ({ } catch (error) { if (isBackendError(error)) { switch (error.label) { + case BackendErrorLabel.INVALID_CONVERSATION_PASSWORD: { + setConversationSubmitData(formLoginData); + setIsLinkPasswordModalOpen(true); + break; + } case BackendErrorLabel.TOO_MANY_CLIENTS: { await resetAuthError(); if (formLoginData?.verificationCode) { @@ -283,7 +325,7 @@ const LoginComponent = ({ setTwoFactorSubmitError(''); // Do not auto submit if already failed once if (!twoFactorSubmitFailedOnce) { - handleSubmit({...twoFactorLoginData, verificationCode: code}, []); + void handleSubmit({...twoFactorLoginData, verificationCode: code}, []); } }; @@ -297,6 +339,14 @@ const LoginComponent = ({ ); + const submitJoinCodeWithPassword = async (password: string) => { + if (!conversationSubmitData) { + setIsLinkPasswordModalOpen(false); + return; + } + await handleSubmit(conversationSubmitData, [], password); + }; + return ( {!embedded && @@ -313,6 +363,19 @@ const LoginComponent = ({ {!isValidLink && } {!embedded && } + {isLinkPasswordModalOpen && ( + { + setIsLinkPasswordModalOpen(false); + void resetAuthError(); + setValidationErrors([]); + }} + error={conversationError} + conversationName={conversationInfo?.name} + isLoading={isFetching || conversationInfoFetching} + onSubmitPassword={submitJoinCodeWithPassword} + /> + )} {!embedded && ( @@ -386,7 +449,7 @@ const LoginComponent = ({ {!Runtime.isDesktopApp() && ( ) => { - pushLoginData({ + void pushLoginData({ clientType: event.target.checked ? ClientType.TEMPORARY : ClientType.PERMANENT, }); }} @@ -440,9 +503,12 @@ type ConnectedProps = ReturnType; const mapStateToProps = (state: RootState) => ({ defaultSSOCode: AuthSelector.getDefaultSSOCode(state), isFetching: AuthSelector.isFetching(state), + conversationError: ConversationSelector.getError(state), isSendingTwoFactorCode: AuthSelector.isSendingTwoFactorCode(state), loginData: AuthSelector.getLoginData(state), authError: AuthSelector.getError(state), + conversationInfo: ConversationSelector.conversationInfo(state), + conversationInfoFetching: ConversationSelector.conversationInfoFetching(state), }); type DispatchProps = ReturnType; @@ -459,6 +525,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => pushEntropyData: actionRoot.authAction.pushEntropyData, pushLoginData: actionRoot.authAction.pushLoginData, resetAuthError: actionRoot.authAction.resetAuthError, + doGetConversationInfoByCode: actionRoot.conversationAction.doGetConversationInfoByCode, }, dispatch, ); diff --git a/src/script/auth/page/SingleSignOnForm.tsx b/src/script/auth/page/SingleSignOnForm.tsx index 227b2826e0d..77999379f42 100644 --- a/src/script/auth/page/SingleSignOnForm.tsx +++ b/src/script/auth/page/SingleSignOnForm.tsx @@ -20,7 +20,7 @@ import React, {useEffect, useRef, useState} from 'react'; import {ClientType} from '@wireapp/api-client/lib/client/index'; -import {BackendErrorLabel, SyntheticErrorLabel} from '@wireapp/api-client/lib/http'; +import {BackendError, BackendErrorLabel, SyntheticErrorLabel} from '@wireapp/api-client/lib/http'; import {pathWithParams} from '@wireapp/commons/lib/util/UrlUtil'; import {isValidEmail, PATTERN} from '@wireapp/commons/lib/util/ValidationUtil'; import {FormattedMessage, useIntl} from 'react-intl'; @@ -42,12 +42,16 @@ import { RoundIconButton, } from '@wireapp/react-ui-kit'; +import {isBackendError} from 'Util/TypePredicateUtil'; + import {Config} from '../../Config'; import {loginStrings, logoutReasonStrings, ssoLoginStrings} from '../../strings'; +import {JoinGuestLinkPasswordModal} from '../component/JoinGuestLinkPasswordModal'; import {actionRoot as ROOT_ACTIONS} from '../module/action/'; import {ValidationError} from '../module/action/ValidationError'; import {bindActionCreators, RootState} from '../module/reducer'; import * as AuthSelector from '../module/selector/AuthSelector'; +import * as ConversationSelector from '../module/selector/ConversationSelector'; import {QUERY_KEY, ROUTE} from '../route'; import {parseError, parseValidationErrors} from '../util/errorUtil'; import {getSearchParams} from '../util/urlUtil'; @@ -63,6 +67,9 @@ const SingleSignOnFormComponent = ({ initialCode, isFetching, authError, + conversationError, + conversationInfo, + conversationInfoFetching, resetAuthError, validateSSOCode, doLogin, @@ -70,6 +77,7 @@ const SingleSignOnFormComponent = ({ doGetDomainInfo, doCheckConversationCode, doJoinConversationByCode, + doGetConversationInfoByCode, doNavigate, }: SingleSignOnFormProps & ConnectedProps & DispatchProps) => { const codeOrMailInput = useRef(); @@ -77,8 +85,8 @@ const SingleSignOnFormComponent = ({ const [disableInput, setDisableInput] = useState(false); const {formatMessage: _} = useIntl(); const navigate = useNavigate(); - const [clientType, setClientType] = useState(null); - const [ssoError, setSsoError] = useState(null); + const [clientType, setClientType] = useState(null); + const [ssoError, setSsoError] = useState(null); const [isCodeOrMailInputValid, setIsCodeOrMailInputValid] = useState(true); const [validationError, setValidationError] = useState(); const [logoutReason, setLogoutReason] = useState(); @@ -90,6 +98,18 @@ const SingleSignOnFormComponent = ({ const [shouldAutoLogin, setShouldAutoLogin] = useState(false); + const [isLinkPasswordModalOpen, setIsLinkPasswordModalOpen] = useState( + !!conversationInfo?.has_password || + (!!conversationError && conversationError.label === BackendErrorLabel.INVALID_CONVERSATION_PASSWORD), + ); + + useEffect(() => { + setIsLinkPasswordModalOpen( + !!conversationInfo?.has_password || + (!!conversationError && conversationError.label === BackendErrorLabel.INVALID_CONVERSATION_PASSWORD), + ); + }, [conversationError, conversationInfo?.has_password]); + useEffect(() => { const queryAutoLogin = UrlUtil.hasURLParameter(QUERY_KEY.SSO_AUTO_LOGIN); if (queryAutoLogin === true && initialCode) { @@ -126,6 +146,10 @@ const SingleSignOnFormComponent = ({ console.warn('Invalid conversation code', error); setIsValidLink(false); }); + doGetConversationInfoByCode(queryConversationKey, queryConversationCode).catch(error => { + console.warn('Failed to fetch conversation info', error); + setIsValidLink(false); + }); } }, []); @@ -147,12 +171,23 @@ const SingleSignOnFormComponent = ({ setIsCodeOrMailInputValid(true); }; - const handleSubmit = async (event?: React.FormEvent): Promise => { + const handleSubmit = async (event?: React.FormEvent, password?: string): Promise => { if (event) { event.preventDefault(); } + resetAuthError(); - if (isFetching) { + + if (isFetching || !codeOrMailInput.current) { + return; + } + + if ( + !isLinkPasswordModalOpen && + (!!conversationInfo?.has_password || + (!!conversationError && conversationError.label === BackendErrorLabel.INVALID_CONVERSATION_PASSWORD)) + ) { + setIsLinkPasswordModalOpen(true); return; } @@ -203,36 +238,42 @@ const SingleSignOnFormComponent = ({ await doFinalizeSSOLogin({clientType}); const hasKeyAndCode = conversationKey && conversationCode; if (hasKeyAndCode) { - await doJoinConversationByCode(conversationKey, conversationCode); + await doJoinConversationByCode(conversationKey, conversationCode, undefined, password); } navigate(ROUTE.HISTORY_INFO); } } catch (error) { setIsLoading(false); - switch (error.label) { - case BackendErrorLabel.TOO_MANY_CLIENTS: { - resetAuthError(); - navigate(ROUTE.CLIENTS); - break; - } - case BackendErrorLabel.CUSTOM_BACKEND_NOT_FOUND: { - setSsoError(error); - break; - } - case SyntheticErrorLabel.SSO_USER_CANCELLED_ERROR: - case BackendErrorLabel.NOT_FOUND: { - break; - } - default: { - setSsoError(error); - const isValidationError = Object.values(ValidationError.ERROR).some( - errorType => error.label && error.label.endsWith(errorType), - ); - if (!isValidationError) { - console.warn('SSO authentication error', JSON.stringify(Object.entries(error)), error); + if (isBackendError(error)) { + switch (error.label) { + case BackendErrorLabel.TOO_MANY_CLIENTS: { + resetAuthError(); + navigate(ROUTE.CLIENTS); + break; + } + case BackendErrorLabel.CUSTOM_BACKEND_NOT_FOUND: { + setSsoError(error); + break; + } + case BackendErrorLabel.INVALID_CONVERSATION_PASSWORD: { + // error will be hanlded by opening modal + break; + } + case SyntheticErrorLabel.SSO_USER_CANCELLED_ERROR: + case BackendErrorLabel.NOT_FOUND: { + break; + } + default: { + setSsoError(error); + const isValidationError = Object.values(ValidationError.ERROR).some( + errorType => error.label && error.label.endsWith(errorType), + ); + if (!isValidationError) { + console.warn('SSO authentication error', JSON.stringify(Object.entries(error)), error); + } + break; } - break; } } } @@ -255,68 +296,85 @@ const SingleSignOnFormComponent = ({ ? `(${SSO_CODE_PREFIX_REGEX}${PATTERN.UUID_V4}|${PATTERN.EMAIL})` : `${SSO_CODE_PREFIX_REGEX}${PATTERN.UUID_V4}`; - return isLoading ? ( - - ) : ( -
- {!isValidLink && } - - - - - - - - - {validationError ? ( - parseValidationErrors([validationError]) - ) : authError ? ( - parseError(authError) - ) : ssoError ? ( - parseError(ssoError) - ) : logoutReason ? ( - - , - }} - /> - - ) : ( -   - )} - {!Runtime.isDesktopApp() && ( - ) => - setClientType(event.target.checked ? ClientType.TEMPORARY : ClientType.PERMANENT) - } - checked={clientType === ClientType.TEMPORARY} - data-uie-name="enter-public-computer-sso-sign-in" - aligncenter - style={{justifyContent: 'center', marginTop: '36px'}} - > - {_(loginStrings.publicComputer)} - + const submitJoinCodeWithPassword = async (password?: string) => { + await handleSubmit(undefined, password); + }; + + if (isLoading) { + return ; + } + + return ( + <> + {isLinkPasswordModalOpen && ( + setIsLinkPasswordModalOpen(false)} + error={conversationError} + conversationName={conversationInfo?.name} + isLoading={isFetching || conversationInfoFetching} + onSubmitPassword={submitJoinCodeWithPassword} + /> )} - +
+ {!isValidLink && } + + + + + + + + + {validationError ? ( + parseValidationErrors([validationError]) + ) : authError ? ( + parseError(authError) + ) : ssoError ? ( + parseError(ssoError) + ) : logoutReason ? ( + + , + }} + /> + + ) : ( +   + )} + {!Runtime.isDesktopApp() && ( + ) => + setClientType(event.target.checked ? ClientType.TEMPORARY : ClientType.PERMANENT) + } + checked={clientType === ClientType.TEMPORARY} + data-uie-name="enter-public-computer-sso-sign-in" + aligncenter + style={{justifyContent: 'center', marginTop: '36px'}} + > + {_(loginStrings.publicComputer)} + + )} + + ); }; @@ -324,6 +382,9 @@ type ConnectedProps = ReturnType; const mapStateToProps = (state: RootState) => ({ isFetching: AuthSelector.isFetching(state), authError: AuthSelector.getError(state), + conversationError: ConversationSelector.getError(state), + conversationInfo: ConversationSelector.conversationInfo(state), + conversationInfoFetching: ConversationSelector.conversationInfoFetching(state), }); type DispatchProps = ReturnType; @@ -334,6 +395,7 @@ const mapDispatchToProps = (dispatch: Dispatch) => doFinalizeSSOLogin: ROOT_ACTIONS.authAction.doFinalizeSSOLogin, doGetDomainInfo: ROOT_ACTIONS.authAction.doGetDomainInfo, doJoinConversationByCode: ROOT_ACTIONS.conversationAction.doJoinConversationByCode, + doGetConversationInfoByCode: ROOT_ACTIONS.conversationAction.doGetConversationInfoByCode, doNavigate: ROOT_ACTIONS.navigationAction.doNavigate, resetAuthError: ROOT_ACTIONS.authAction.resetAuthError, validateSSOCode: ROOT_ACTIONS.authAction.validateSSOCode, diff --git a/src/script/components/CopyToClipboardButton.test.tsx b/src/script/components/CopyToClipboardButton.test.tsx new file mode 100644 index 00000000000..b7a687eed73 --- /dev/null +++ b/src/script/components/CopyToClipboardButton.test.tsx @@ -0,0 +1,72 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {render, waitFor, act} from '@testing-library/react'; + +import {CopyToClipboardButton} from './CopyToClipboardButton'; + +import {withTheme} from '../auth/util/test/TestUtil'; + +jest.mock('Util/ClipboardUtil', () => ({ + copyText: jest.fn(), +})); + +describe('CopyToClipboardButton', () => { + const textToCopy = 'some text'; + const displayText = 'Copy to Clipboard'; + const copySuccessText = 'Copied!'; + const onCopySuccess = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('renders the button with the correct text', () => { + const {getByText} = render( + withTheme( + , + ), + ); + expect(getByText(displayText)).toBeTruthy(); + }); + + it('copies text to clipboard and displays success message', async () => { + const {getByText} = render( + withTheme( + , + ), + ); + + const button = getByText(displayText); + + act(() => { + button.click(); + }); + + await waitFor(() => { + expect(onCopySuccess).toHaveBeenCalledTimes(1); + expect(getByText(copySuccessText)).toBeTruthy(); + }); + }); +}); diff --git a/src/script/components/CopyToClipboardButton.tsx b/src/script/components/CopyToClipboardButton.tsx new file mode 100644 index 00000000000..23565ba822e --- /dev/null +++ b/src/script/components/CopyToClipboardButton.tsx @@ -0,0 +1,71 @@ +/* + * Wire + * Copyright (C) 2023 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import React, {useState} from 'react'; + +import {Button, ButtonVariant} from '@wireapp/react-ui-kit'; + +import {Icon} from 'Components/Icon'; +import {copyText} from 'Util/ClipboardUtil'; + +interface CopyToClipboardButtonProps { + textToCopy: string; + displayText: string; + copySuccessText: string; + onCopySuccess?: () => void; + disabled?: boolean; +} + +const COPY_CONFIRM_DURATION = 1500; + +const CopyToClipboardButton: React.FC = ({ + disabled, + textToCopy, + displayText, + copySuccessText, + onCopySuccess, +}) => { + const [isCopying, setIsCopying] = useState(false); + + const copyToClipboard = async () => { + if (disabled) { + return; + } + if (!isCopying) { + await copyText(textToCopy); + onCopySuccess?.(); + setIsCopying(true); + window.setTimeout(() => setIsCopying(false), COPY_CONFIRM_DURATION); + } + }; + + return ( + + ); +}; + +export {CopyToClipboardButton}; diff --git a/src/style/components/copy-to-clipboard.less b/src/script/components/Modals/PrimaryModal/PrimaryModal.styles.ts similarity index 63% rename from src/style/components/copy-to-clipboard.less rename to src/script/components/Modals/PrimaryModal/PrimaryModal.styles.ts index b922211ab8a..2a670c45de4 100644 --- a/src/style/components/copy-to-clipboard.less +++ b/src/script/components/Modals/PrimaryModal/PrimaryModal.styles.ts @@ -1,6 +1,6 @@ /* * Wire - * Copyright (C) 2018 Wire Swiss GmbH + * Copyright (C) 2023 Wire Swiss GmbH * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,10 +17,15 @@ * */ -copy-to-clipboard { - display: block; +import {CSSObject} from '@emotion/react'; - .copy-to-clipboard { - word-break: break-all; - } -} +export const guestLinkPasswordInputStyles: CSSObject = { + flexDirection: 'column', + input: { + borderRadius: 12, + border: '1px solid var(--button-tertiary-border)', + boxShadow: 'none !important', + '&:hover': {boxShadow: 'none'}, + '&:focus': {border: '1px solid var(--accent-color)'}, + }, +}; diff --git a/src/script/components/Modals/PrimaryModal/PrimaryModal.tsx b/src/script/components/Modals/PrimaryModal/PrimaryModal.tsx index 4244d5f20dd..f0dfb4b084c 100644 --- a/src/script/components/Modals/PrimaryModal/PrimaryModal.tsx +++ b/src/script/components/Modals/PrimaryModal/PrimaryModal.tsx @@ -22,22 +22,28 @@ import {FC, FormEvent, MouseEvent, useState, useRef, ChangeEvent, useEffect} fro import cx from 'classnames'; import {ValidationUtil} from '@wireapp/commons'; -import {Checkbox, CheckboxLabel, Input, Loading} from '@wireapp/react-ui-kit'; +import {Checkbox, CheckboxLabel, COLOR, Form, Link, Text, Input, Loading} from '@wireapp/react-ui-kit'; +import {CopyToClipboardButton} from 'Components/CopyToClipboardButton'; import {FadingScrollbar} from 'Components/FadingScrollbar'; import {Icon} from 'Components/Icon'; import {ModalComponent} from 'Components/ModalComponent'; +import {PasswordGeneratorButton} from 'Components/PasswordGeneratorButton'; import {Config} from 'src/script/Config'; import {isEscapeKey} from 'Util/KeyboardUtil'; import {t} from 'Util/LocalizerUtil'; +import {isValidPassword} from 'Util/StringUtil'; +import {guestLinkPasswordInputStyles} from './PrimaryModal.styles'; import {usePrimaryModalState, showNextModalInQueue, defaultContent, removeCurrentModal} from './PrimaryModalState'; import {Action, PrimaryModalType} from './PrimaryModalTypes'; export const PrimaryModalComponent: FC = () => { const [inputValue, updateInputValue] = useState(''); - const [passwordValue, updatePasswordValue] = useState(''); + const [passwordValue, setPasswordValue] = useState(''); const [passwordInput, updatePasswordWithRules] = useState(''); + const [passwordConfirmationValue, setPasswordConfirmationValue] = useState(''); + const [didCopyPassword, setDidCopyPassword] = useState(false); const [optionChecked, updateOptionChecked] = useState(false); const content = usePrimaryModalState(state => state.currentModalContent); const errorMessage = usePrimaryModalState(state => state.errorMessage); @@ -51,32 +57,42 @@ export const PrimaryModalComponent: FC = () => { closeOnConfirm, currentType, inputPlaceholder, - messageHtml, - messageText, + message, modalUie, onBgClick, primaryAction, secondaryAction, titleText, closeBtnTitle, + copyPassword, hideCloseBtn = false, passwordOptional = false, } = content; + + const isPassword = currentType === PrimaryModalType.PASSWORD; const showLoadingIndicator = currentType === PrimaryModalType.LOADING; - const hasPassword = currentType === PrimaryModalType.PASSWORD; const hasPasswordWithRules = currentType === PrimaryModalType.PASSWORD_ADVANCED_SECURITY; - const hasInput = currentType === PrimaryModalType.INPUT; - const hasOption = currentType === PrimaryModalType.OPTION; + const isInput = currentType === PrimaryModalType.INPUT; + const isOption = currentType === PrimaryModalType.OPTION; const hasMultipleSecondary = currentType === PrimaryModalType.MULTI_ACTIONS; + const isGuestLinkPassword = currentType === PrimaryModalType.GUEST_LINK_PASSWORD; + const isJoinGuestLinkPassword = currentType === PrimaryModalType.JOIN_GUEST_LINK_PASSWORD; + const isConfirm = currentType === PrimaryModalType.CONFIRM; + + const isPasswordRequired = hasPasswordWithRules || isGuestLinkPassword; + const onModalHidden = () => { updateCurrentModalContent(defaultContent); updateInputValue(''); - updatePasswordValue(''); + setPasswordValue(''); updatePasswordWithRules(''); updateErrorMessage(''); updateOptionChecked(false); showNextModalInQueue(); + setPasswordConfirmationValue(''); + setDidCopyPassword(false); }; + const isPasswordOptional = () => { const skipValidation = passwordOptional && !passwordInput.trim().length; if (skipValidation) { @@ -84,19 +100,36 @@ export const PrimaryModalComponent: FC = () => { } return passwordRegex.test(passwordInput); }; + const checkGuestLinkPassword = (password: string, passwordConfirm: string): boolean => { + if (password !== passwordConfirm) { + return false; + } + return isValidPassword(password); + }; const passwordRegex = new RegExp( ValidationUtil.getNewPasswordPattern(Config.getConfig().NEW_PASSWORD_MINIMUM_LENGTH), ); - const actionEnabled = - (!hasInput || !!inputValue.trim().length) && (hasPasswordWithRules ? isPasswordOptional() : true); + const actionEnabled = isPasswordRequired ? isPasswordOptional() : true; + const inputActionEnabled = !isInput || !!inputValue.trim().length; + + const passwordGuestLinkActionEnabled = + (!isGuestLinkPassword || !!passwordValue.trim().length) && + checkGuestLinkPassword(passwordValue, passwordConfirmationValue); + + const isPrimaryActionDisabled = () => { + if (isConfirm) { + return false; + } + return (!inputActionEnabled || !passwordGuestLinkActionEnabled) && !actionEnabled; + }; const doAction = (action?: Function, closeAfter = true, skipValidation = false) => (event: FormEvent | MouseEvent) => { event.preventDefault(); - if (!skipValidation && !actionEnabled) { + if (!skipValidation && !inputActionEnabled) { return; } if (typeof action === 'function') { @@ -109,19 +142,23 @@ export const PrimaryModalComponent: FC = () => { const confirm = () => { const action = content?.primaryAction?.action; - if (typeof action === 'function') { - const actions = { - [PrimaryModalType.OPTION]: () => action(optionChecked), - [PrimaryModalType.INPUT]: () => action(inputValue), - [PrimaryModalType.PASSWORD]: () => action(passwordValue), - [PrimaryModalType.PASSWORD_ADVANCED_SECURITY]: () => action(passwordInput), - }; - if (Object.keys(actions).includes(content?.currentType ?? '')) { - actions[content?.currentType as keyof typeof actions](); - return; - } - action(); + if (!action) { + return; } + const actions = { + [PrimaryModalType.OPTION]: () => action(optionChecked), + [PrimaryModalType.INPUT]: () => action(inputValue), + [PrimaryModalType.PASSWORD]: () => action(passwordValue), + [PrimaryModalType.GUEST_LINK_PASSWORD]: () => action(passwordValue, didCopyPassword), + [PrimaryModalType.JOIN_GUEST_LINK_PASSWORD]: () => action(passwordValue), + [PrimaryModalType.PASSWORD_ADVANCED_SECURITY]: () => action(passwordInput), + }; + + if (Object.keys(actions).includes(content?.currentType ?? '')) { + actions[content?.currentType as keyof typeof actions](); + return; + } + action(); }; const onOptionChange = (event: ChangeEvent) => { @@ -194,14 +231,72 @@ export const PrimaryModalComponent: FC = () => { - {(messageHtml || messageText) && ( + {message && (
- {messageHtml && } + {message && }
)} - {hasPassword && ( + {isGuestLinkPassword && ( + { + setPasswordValue(password); + setPasswordConfirmationValue(password); + }} + /> + )} + + {isGuestLinkPassword && ( +
+ setPasswordValue(event.currentTarget.value)} + /> + setPasswordConfirmationValue(event.currentTarget.value)} + /> +
+ )} + + {copyPassword && ( + setDidCopyPassword(true)} + /> + )} + + {isPassword && (