diff --git a/.eslintrc.js b/.eslintrc.js index 444388d492..a3c7eb4f8d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -122,8 +122,6 @@ module.exports = { "!matrix-js-sdk/src/crypto/aes", "!matrix-js-sdk/src/crypto/keybackup", "!matrix-js-sdk/src/crypto/deviceinfo", - "!matrix-js-sdk/src/crypto/key_passphrase", - "!matrix-js-sdk/src/crypto/recoverykey", "!matrix-js-sdk/src/crypto/dehydration", "!matrix-js-sdk/src/oidc", "!matrix-js-sdk/src/oidc/discovery", diff --git a/.github/workflows/notify-element-web.yml b/.github/workflows/notify-element-web.yml index 522a089edb..442a2f1082 100644 --- a/.github/workflows/notify-element-web.yml +++ b/.github/workflows/notify-element-web.yml @@ -9,7 +9,7 @@ jobs: name: "Notify Element Web" runs-on: ubuntu-latest # Only respect triggers from our develop branch, ignore that of forks - if: github.repository == 'matrix-org/matrix-react-sdk' + if: github.repository == 'element-hq/matrix-react-sdk' steps: - name: Notify element-web repo that a new SDK build is on develop uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3 diff --git a/.github/workflows/playwright-image-updates.yaml b/.github/workflows/playwright-image-updates.yaml index 99f7465396..4d4c08f566 100644 --- a/.github/workflows/playwright-image-updates.yaml +++ b/.github/workflows/playwright-image-updates.yaml @@ -20,7 +20,7 @@ jobs: - name: Create Pull Request id: cpr - uses: peter-evans/create-pull-request@4320041ed380b20e97d388d56a7fb4f9b8c20e79 # v7 + uses: peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f # v7 with: token: ${{ secrets.ELEMENT_BOT_TOKEN }} branch: actions/playwright-image-updates diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eddca83555..9d1cb6e2d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,9 @@ concurrency: ${{ github.workflow }} jobs: release: uses: matrix-org/matrix-js-sdk/.github/workflows/release-make.yml@develop - secrets: inherit + secrets: + ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} with: final: ${{ inputs.mode == 'final' }} npm: ${{ inputs.npm }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e8418a9519..22a02779b3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -39,7 +39,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 with: - repository: ${{ inputs.matrix-js-sdk-sha && 'matrix-org/matrix-react-sdk' || github.repository }} + repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/matrix-react-sdk' || github.repository }} - name: Yarn cache uses: actions/setup-node@v4 @@ -96,7 +96,7 @@ jobs: needs: jest steps: - name: Skip SonarCloud - uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1 + uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1 with: authToken: ${{ secrets.GITHUB_TOKEN }} state: success @@ -111,7 +111,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - repository: ${{ inputs.matrix-js-sdk-sha && 'matrix-org/matrix-react-sdk' || github.repository }} + repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/matrix-react-sdk' || github.repository }} - uses: actions/setup-node@v4 with: diff --git a/package.json b/package.json index 00301bef50..35718c7713 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ }, "resolutions": { "@types/react-dom": "17.0.25", - "@types/react": "17.0.80", + "@types/react": "17.0.82", "@types/seedrandom": "3.0.8", "oidc-client-ts": "3.0.1", "jwt-decode": "4.0.0", @@ -91,7 +91,7 @@ "classnames": "^2.2.6", "commonmark": "^0.31.0", "counterpart": "^0.18.6", - "css-tree": "^2.3.1", + "css-tree": "^3.0.0", "diff-dom": "^5.0.0", "diff-match-patch": "^1.0.5", "emojibase-regex": "15.3.2", @@ -183,7 +183,7 @@ "@types/node-fetch": "^2.6.2", "@types/pako": "^2.0.0", "@types/qrcode": "^1.3.5", - "@types/react": "17.0.80", + "@types/react": "17.0.82", "@types/react-beautiful-dnd": "^13.0.0", "@types/react-dom": "17.0.25", "@types/react-transition-group": "^4.4.0", @@ -198,7 +198,7 @@ "axe-core": "4.10.0", "babel-jest": "^29.0.0", "blob-polyfill": "^9.0.0", - "eslint": "8.57.0", + "eslint": "8.57.1", "eslint-config-google": "^0.14.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-deprecate": "0.8.5", diff --git a/playwright/pages/bot.ts b/playwright/pages/bot.ts index b7542338b6..d50a0e84ee 100644 --- a/playwright/pages/bot.ts +++ b/playwright/pages/bot.ts @@ -14,7 +14,7 @@ import type { Logger } from "matrix-js-sdk/src/logger"; import type { SecretStorageKeyDescription } from "matrix-js-sdk/src/secret-storage"; import type { Credentials, HomeserverInstance } from "../plugins/homeserver"; import type { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; -import { Client } from "./client"; +import { bootstrapCrossSigningForClient, Client } from "./client"; export interface CreateBotOpts { /** @@ -90,9 +90,13 @@ export class Bot extends Client { } protected async getClientHandle(): Promise> { - if (this.handlePromise) return this.handlePromise; + if (!this.handlePromise) this.handlePromise = this.buildClient(); + return this.handlePromise; + } - this.handlePromise = this.page.evaluateHandle( + private async buildClient(): Promise> { + const credentials = await this.getCredentials(); + const clientHandle = await this.page.evaluateHandle( async ({ homeserver, credentials, opts }) => { function getLogger(loggerName: string): Logger { const logger = { @@ -172,53 +176,50 @@ export class Bot extends Client { }); } - if (!opts.startClient) { - return cli; - } - - await cli.initRustCrypto({ useIndexedDB: false }); - cli.setGlobalErrorOnUnknownDevices(false); - await cli.startClient(); - - if (opts.bootstrapCrossSigning) { - // XXX: workaround https://github.com/element-hq/element-web/issues/26755 - // wait for out device list to be available, as a proxy for the device keys having been uploaded. - await cli.getCrypto()!.getUserDeviceInfo([credentials.userId]); - - await cli.getCrypto()!.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: async (func) => { - await func({ - type: "m.login.password", - identifier: { - type: "m.id.user", - user: credentials.userId, - }, - password: credentials.password, - }); - }, - }); - } - - if (opts.bootstrapSecretStorage) { - const passphrase = "new passphrase"; - const recoveryKey = await cli.getCrypto().createRecoveryKeyFromPassphrase(passphrase); - Object.assign(cli, { __playwright_recovery_key: recoveryKey }); - - await cli.getCrypto()!.bootstrapSecretStorage({ - setupNewSecretStorage: true, - setupNewKeyBackup: true, - createSecretStorageKey: () => Promise.resolve(recoveryKey), - }); - } - return cli; }, { homeserver: this.homeserver.config, - credentials: await this.getCredentials(), + credentials, opts: this.opts, }, ); - return this.handlePromise; + + // If we weren't configured to start the client, bail out now. + if (!this.opts.startClient) { + return clientHandle; + } + + await clientHandle.evaluate(async (cli) => { + await cli.initRustCrypto({ useIndexedDB: false }); + cli.setGlobalErrorOnUnknownDevices(false); + await cli.startClient(); + }); + + if (this.opts.bootstrapCrossSigning) { + // XXX: workaround https://github.com/element-hq/element-web/issues/26755 + // wait for out device list to be available, as a proxy for the device keys having been uploaded. + await clientHandle.evaluate(async (cli, credentials) => { + await cli.getCrypto()!.getUserDeviceInfo([credentials.userId]); + }, credentials); + + await bootstrapCrossSigningForClient(clientHandle, credentials); + } + + if (this.opts.bootstrapSecretStorage) { + await clientHandle.evaluate(async (cli) => { + const passphrase = "new passphrase"; + const recoveryKey = await cli.getCrypto().createRecoveryKeyFromPassphrase(passphrase); + Object.assign(cli, { __playwright_recovery_key: recoveryKey }); + + await cli.getCrypto()!.bootstrapSecretStorage({ + setupNewSecretStorage: true, + setupNewKeyBackup: true, + createSecretStorageKey: () => Promise.resolve(recoveryKey), + }); + }); + } + + return clientHandle; } } diff --git a/playwright/pages/client.ts b/playwright/pages/client.ts index 002a3340b2..06e05fdcfa 100644 --- a/playwright/pages/client.ts +++ b/playwright/pages/client.ts @@ -356,24 +356,11 @@ export class Client { } /** - * Boostraps cross-signing. + * Bootstraps cross-signing. */ public async bootstrapCrossSigning(credentials: Credentials): Promise { const client = await this.prepareClient(); - return client.evaluate(async (client, credentials) => { - await client.getCrypto().bootstrapCrossSigning({ - authUploadDeviceSigningKeys: async (func) => { - await func({ - type: "m.login.password", - identifier: { - type: "m.id.user", - user: credentials.userId, - }, - password: credentials.password, - }); - }, - }); - }, credentials); + return bootstrapCrossSigningForClient(client, credentials); } /** @@ -439,3 +426,31 @@ export class Client { ); } } + +/** Call `CryptoApi.bootstrapCrossSigning` on the given Matrix client, using the given credentials to authenticate + * the UIA request. + */ +export function bootstrapCrossSigningForClient( + client: JSHandle, + credentials: Credentials, + resetKeys: boolean = false, +) { + return client.evaluate( + async (client, { credentials, resetKeys }) => { + await client.getCrypto().bootstrapCrossSigning({ + authUploadDeviceSigningKeys: async (func) => { + await func({ + type: "m.login.password", + identifier: { + type: "m.id.user", + user: credentials.userId, + }, + password: credentials.password, + }); + }, + setupNewCrossSigning: resetKeys, + }); + }, + { credentials, resetKeys }, + ); +} diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index 6bfd361140..fb73e25389 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -7,8 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import { ICryptoCallbacks, SecretStorage } from "matrix-js-sdk/src/matrix"; -import { deriveKey } from "matrix-js-sdk/src/crypto/key_passphrase"; -import { decodeRecoveryKey } from "matrix-js-sdk/src/crypto/recoverykey"; +import { deriveRecoveryKeyFromPassphrase, decodeRecoveryKey } from "matrix-js-sdk/src/crypto-api"; import { logger } from "matrix-js-sdk/src/logger"; import type CreateSecretStorageDialog from "./async-components/views/dialogs/security/CreateSecretStorageDialog"; @@ -64,7 +63,7 @@ function makeInputToKey( ): (keyParams: KeyParams) => Promise { return async ({ passphrase, recoveryKey }): Promise => { if (passphrase) { - return deriveKey(passphrase, keyInfo.passphrase.salt, keyInfo.passphrase.iterations); + return deriveRecoveryKeyFromPassphrase(passphrase, keyInfo.passphrase.salt, keyInfo.passphrase.iterations); } else if (recoveryKey) { return decodeRecoveryKey(recoveryKey); } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 8e0eaabe4f..1726c8462d 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -952,18 +952,20 @@ export default class MatrixChat extends React.PureComponent { } private async startRegistration(params: { [key: string]: string }, isMobileRegistration?: boolean): Promise { - if (!SettingsStore.getValue(UIFeature.Registration)) { + // If registration is disabled or mobile registration is requested but not enabled in settings redirect to the welcome screen + if ( + !SettingsStore.getValue(UIFeature.Registration) || + (isMobileRegistration && !SettingsStore.getValue("Registration.mobileRegistrationHelper")) + ) { this.showScreen("welcome"); return; } - const isMobileRegistrationAllowed = - isMobileRegistration && SettingsStore.getValue("Registration.mobileRegistrationHelper"); const newState: Partial = { view: Views.REGISTER, }; - if (isMobileRegistrationAllowed && params.hs_url) { + if (isMobileRegistration && params.hs_url) { try { const config = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(params.hs_url); newState.serverConfig = config; @@ -992,12 +994,12 @@ export default class MatrixChat extends React.PureComponent { newState.register_id_sid = params.sid; } - newState.isMobileRegistration = isMobileRegistrationAllowed; + newState.isMobileRegistration = isMobileRegistration; this.setStateForNewView(newState); ThemeController.isLogin = true; this.themeWatcher.recheck(); - this.notifyNewScreen(isMobileRegistrationAllowed ? "mobile_register" : "register"); + this.notifyNewScreen(isMobileRegistration ? "mobile_register" : "register"); } // switch view to the given room diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index a65743a3e2..87e8c3c307 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -1364,7 +1364,7 @@ export class RoomView extends React.Component { if (containsEmoji(ev.getContent(), effect.emojis) || ev.getContent().msgtype === effect.msgType) { // For initial threads launch, chat effects are disabled see #19731 if (!ev.isRelation(THREAD_RELATION_TYPE.name)) { - dis.dispatch({ action: `effects.${effect.command}` }); + dis.dispatch({ action: `effects.${effect.command}`, event: ev }); } } }); diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index 2dc9125362..0ae5c93346 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -627,6 +627,7 @@ export default class Registration extends React.Component { serverConfig={this.props.serverConfig} canSubmit={!this.state.serverErrorIsFatal} matrixClient={this.state.matrixClient} + mobileRegister={this.props.mobileRegister} /> ); @@ -779,7 +780,11 @@ export default class Registration extends React.Component { ); } if (this.props.mobileRegister) { - return
{body}
; + return ( +
+ {body} +
+ ); } return ( diff --git a/src/components/views/auth/CaptchaForm.tsx b/src/components/views/auth/CaptchaForm.tsx index 2b82f4cea0..f216f004fd 100644 --- a/src/components/views/auth/CaptchaForm.tsx +++ b/src/components/views/auth/CaptchaForm.tsx @@ -63,6 +63,19 @@ export default class CaptchaForm extends React.Component { id?: string; @@ -22,6 +23,7 @@ interface IProps extends Omit { label: TranslationKey; labelRequired: TranslationKey; labelInvalid: TranslationKey; + tooltipAlignment?: Alignment; // When present, completely overrides the default validation rules. validationRules?: (fieldState: IFieldState) => Promise; @@ -77,6 +79,7 @@ class EmailField extends PureComponent { autoFocus={this.props.autoFocus} onChange={this.props.onChange} onValidate={this.onValidate} + tooltipAlignment={this.props.tooltipAlignment} /> ); } diff --git a/src/components/views/auth/PassphraseConfirmField.tsx b/src/components/views/auth/PassphraseConfirmField.tsx index b72f61310d..ec26099ded 100644 --- a/src/components/views/auth/PassphraseConfirmField.tsx +++ b/src/components/views/auth/PassphraseConfirmField.tsx @@ -11,6 +11,7 @@ import React, { PureComponent, RefCallback, RefObject } from "react"; import Field, { IInputProps } from "../elements/Field"; import withValidation, { IFieldState, IValidationResult } from "../elements/Validation"; import { _t, _td, TranslationKey } from "../../../languageHandler"; +import { Alignment } from "../elements/Tooltip"; interface IProps extends Omit { id?: string; @@ -22,7 +23,7 @@ interface IProps extends Omit { label: TranslationKey; labelRequired: TranslationKey; labelInvalid: TranslationKey; - + tooltipAlignment?: Alignment; onChange(ev: React.FormEvent): void; onValidate?(result: IValidationResult): void; } @@ -70,6 +71,7 @@ class PassphraseConfirmField extends PureComponent { onChange={this.props.onChange} onValidate={this.onValidate} autoFocus={this.props.autoFocus} + tooltipAlignment={this.props.tooltipAlignment} /> ); } diff --git a/src/components/views/auth/PassphraseField.tsx b/src/components/views/auth/PassphraseField.tsx index 985cf7724d..6770b141a5 100644 --- a/src/components/views/auth/PassphraseField.tsx +++ b/src/components/views/auth/PassphraseField.tsx @@ -15,6 +15,7 @@ import withValidation, { IFieldState, IValidationResult } from "../elements/Vali import { _t, _td, TranslationKey } from "../../../languageHandler"; import Field, { IInputProps } from "../elements/Field"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { Alignment } from "../elements/Tooltip"; interface IProps extends Omit { autoFocus?: boolean; @@ -30,6 +31,7 @@ interface IProps extends Omit { labelEnterPassword: TranslationKey; labelStrongPassword: TranslationKey; labelAllowedButUnsafe: TranslationKey; + tooltipAlignment?: Alignment; onChange(ev: React.FormEvent): void; onValidate?(result: IValidationResult): void; @@ -111,6 +113,7 @@ class PassphraseField extends PureComponent { value={this.props.value} onChange={this.props.onChange} onValidate={this.onValidate} + tooltipAlignment={this.props.tooltipAlignment} /> ); } diff --git a/src/components/views/auth/RegistrationForm.tsx b/src/components/views/auth/RegistrationForm.tsx index c8f7fd3d0f..4df3313758 100644 --- a/src/components/views/auth/RegistrationForm.tsx +++ b/src/components/views/auth/RegistrationForm.tsx @@ -26,6 +26,7 @@ import RegistrationEmailPromptDialog from "../dialogs/RegistrationEmailPromptDia import CountryDropdown from "./CountryDropdown"; import PassphraseConfirmField from "./PassphraseConfirmField"; import { PosthogAnalytics } from "../../../PosthogAnalytics"; +import { Alignment } from "../elements/Tooltip"; enum RegistrationField { Email = "field_email", @@ -58,6 +59,7 @@ interface IProps { serverConfig: ValidatedServerConfig; canSubmit?: boolean; matrixClient: MatrixClient; + mobileRegister?: boolean; onRegisterClick(params: { username: string; @@ -439,6 +441,13 @@ export default class RegistrationForm extends React.PureComponent ); } @@ -468,6 +478,7 @@ export default class RegistrationForm extends React.PureComponent ); } @@ -482,6 +493,7 @@ export default class RegistrationForm extends React.PureComponent ); } @@ -526,6 +538,9 @@ export default class RegistrationForm extends React.PureComponent ); } @@ -557,14 +572,28 @@ export default class RegistrationForm extends React.PureComponent +
{this.renderPassword()}
+
{this.renderPasswordConfirm()}
+ + ); + } else { + passwordFields = ( +
+ {this.renderPassword()} + {this.renderPasswordConfirm()} +
+ ); + } + return (
{this.renderUsername()}
-
- {this.renderPassword()} - {this.renderPasswordConfirm()} -
+ {passwordFields}
{this.renderEmail()} {this.renderPhoneNumber()} diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx index 3759e11063..0c4e875607 100644 --- a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx @@ -10,6 +10,7 @@ import { debounce } from "lodash"; import classNames from "classnames"; import React, { ChangeEvent, FormEvent } from "react"; import { logger } from "matrix-js-sdk/src/logger"; +import { decodeRecoveryKey } from "matrix-js-sdk/src/crypto-api"; import { SecretStorage } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../../../../MatrixClientPeg"; @@ -100,7 +101,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent => {}, /* forceReset = */ true); }; + /** + * Check if the recovery key is valid + * @param recoveryKey + * @private + */ + private isValidRecoveryKey(recoveryKey: string): boolean { + try { + decodeRecoveryKey(recoveryKey); + return true; + } catch (e) { + return false; + } + } + private onRecoveryKeyChange = (e: ChangeEvent): void => { this.setState({ recoveryKey: e.target.value, - recoveryKeyValid: MatrixClientPeg.safeGet().isValidRecoveryKey(e.target.value), + recoveryKeyValid: this.isValidRecoveryKey(e.target.value), }); }; @@ -184,7 +198,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent = ({ roomWidth }) => { canvasRef.current.height = UIStore.instance.windowHeight; } }; - const onAction = (payload: { action: string }): void => { + const onAction = (payload: { action: string; event?: MatrixEvent }): void => { const actionPrefix = "effects."; - if (canvasRef.current && payload.action.startsWith(actionPrefix)) { + const isOutdated = isEventOutdated(payload.event); + if (canvasRef.current && payload.action.startsWith(actionPrefix) && !isOutdated) { const effect = payload.action.slice(actionPrefix.length); lazyLoadEffectModule(effect).then((module) => module?.start(canvasRef.current!)); } @@ -88,3 +90,19 @@ const EffectsOverlay: FunctionComponent = ({ roomWidth }) => { }; export default EffectsOverlay; + +// 48 hours +// 48h * 60m * 60s * 1000ms +const OUTDATED_EVENT_THRESHOLD = 48 * 60 * 60 * 1000; + +/** + * Return true if the event is older than 48h. + * @param event + */ +function isEventOutdated(event?: MatrixEvent): boolean { + if (!event) return false; + + const nowTs = Date.now(); + const eventTs = event.getTs(); + return nowTs - eventTs > OUTDATED_EVENT_THRESHOLD; +} diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx index 4326f63cd7..6cc5dffc40 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -17,7 +17,7 @@ import classNames from "classnames"; import { debounce } from "lodash"; import { IFieldState, IValidationResult } from "./Validation"; -import Tooltip from "./Tooltip"; +import Tooltip, { Alignment } from "./Tooltip"; import { Key } from "../../../Keyboard"; // Invoke validation from user input (when typing, etc.) at most once every N ms. @@ -60,6 +60,8 @@ interface IProps { tooltipContent?: React.ReactNode; // If specified the tooltip will be shown regardless of feedback forceTooltipVisible?: boolean; + // If specified, the tooltip with be aligned accorindly with the field, defaults to Right. + tooltipAlignment?: Alignment; // If specified alongside tooltipContent, the class name to apply to the // tooltip itself. tooltipClassName?: string; @@ -261,6 +263,7 @@ export default class Field extends React.PureComponent { validateOnFocus, usePlaceholderAsHint, forceTooltipVisible, + tooltipAlignment, ...inputProps } = this.props; @@ -286,7 +289,7 @@ export default class Field extends React.PureComponent { tooltipClassName={classNames("mx_Field_tooltip", "mx_Tooltip_noMargin", tooltipClassName)} visible={visible} label={tooltipContent || this.state.feedback} - alignment={Tooltip.Alignment.Right} + alignment={tooltipAlignment || Alignment.Right} role={role} /> ); diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index e5435f595e..88e79d97f2 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -175,7 +175,7 @@ export class StopGapWidgetDriver extends WidgetDriver { WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomCreate).raw, ); - const sendRecvRoomEvents = ["io.element.call.encryption_keys"]; + const sendRecvRoomEvents = ["io.element.call.encryption_keys", EventType.Reaction, EventType.RoomRedaction]; for (const eventType of sendRecvRoomEvents) { this.allowedCapabilities.add(WidgetEventCapability.forRoomEvent(EventDirection.Send, eventType).raw); this.allowedCapabilities.add(WidgetEventCapability.forRoomEvent(EventDirection.Receive, eventType).raw); diff --git a/test/components/structures/MatrixChat-test.tsx b/test/components/structures/MatrixChat-test.tsx index bae633b159..1003d1d167 100644 --- a/test/components/structures/MatrixChat-test.tsx +++ b/test/components/structures/MatrixChat-test.tsx @@ -55,6 +55,7 @@ import { MatrixClientPeg as peg } from "../../../src/MatrixClientPeg"; import DMRoomMap from "../../../src/utils/DMRoomMap"; import { ReleaseAnnouncementStore } from "../../../src/stores/ReleaseAnnouncementStore"; import { DRAFT_LAST_CLEANUP_KEY } from "../../../src/DraftCleaner"; +import { UIFeature } from "../../../src/settings/UIFeature"; jest.mock("matrix-js-sdk/src/oidc/authorize", () => ({ completeAuthorizationCodeGrant: jest.fn(), @@ -1462,4 +1463,42 @@ describe("", () => { }); }); }); + + describe("mobile registration", () => { + const getComponentAndWaitForReady = async (): Promise => { + const renderResult = getComponent(); + // wait for welcome page chrome render + await screen.findByText("powered by Matrix"); + + // go to mobile_register page + defaultDispatcher.dispatch({ + action: "start_mobile_registration", + }); + + await flushPromises(); + + return renderResult; + }; + + const enabledMobileRegistration = (): void => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => { + if (settingName === "Registration.mobileRegistrationHelper") return true; + if (settingName === UIFeature.Registration) return true; + }); + }; + + it("should render welcome screen if mobile registration is not enabled in settings", async () => { + await getComponentAndWaitForReady(); + + await screen.findByText("powered by Matrix"); + }); + + it("should render mobile registration", async () => { + enabledMobileRegistration(); + + await getComponentAndWaitForReady(); + + expect(screen.getByTestId("mobile-register")).toBeInTheDocument(); + }); + }); }); diff --git a/test/components/structures/auth/Registration-test.tsx b/test/components/structures/auth/Registration-test.tsx index c31eff9b7c..9a83f00a9d 100644 --- a/test/components/structures/auth/Registration-test.tsx +++ b/test/components/structures/auth/Registration-test.tsx @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { fireEvent, render, screen, waitForElementToBeRemoved } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved } from "@testing-library/react"; import { createClient, MatrixClient, MatrixError, OidcClientConfig } from "matrix-js-sdk/src/matrix"; import { mocked, MockedObject } from "jest-mock"; import fetchMock from "fetch-mock-jest"; @@ -87,12 +87,23 @@ describe("Registration", function () { const defaultHsUrl = "https://matrix.org"; const defaultIsUrl = "https://vector.im"; - function getRawComponent(hsUrl = defaultHsUrl, isUrl = defaultIsUrl, authConfig?: OidcClientConfig) { - return ; + function getRawComponent( + hsUrl = defaultHsUrl, + isUrl = defaultIsUrl, + authConfig?: OidcClientConfig, + mobileRegister?: boolean, + ) { + return ( + + ); } - function getComponent(hsUrl?: string, isUrl?: string, authConfig?: OidcClientConfig) { - return render(getRawComponent(hsUrl, isUrl, authConfig)); + function getComponent(hsUrl?: string, isUrl?: string, authConfig?: OidcClientConfig, mobileRegister?: boolean) { + return render(getRawComponent(hsUrl, isUrl, authConfig, mobileRegister)); } it("should show server picker", async function () { @@ -208,5 +219,31 @@ describe("Registration", function () { ); }); }); + + describe("when is mobile registeration", () => { + it("should not show server picker", async function () { + const { container } = getComponent(defaultHsUrl, defaultIsUrl, undefined, true); + expect(container.querySelector(".mx_ServerPicker")).toBeFalsy(); + }); + + it("should show username field with autocaps disabled", async function () { + const { container } = getComponent(defaultHsUrl, defaultIsUrl, undefined, true); + + await waitFor(() => + expect(container.querySelector("#mx_RegistrationForm_username")).toHaveAttribute( + "autocapitalize", + "none", + ), + ); + }); + + it("should show password and confirm password fields in separate rows", async function () { + const { container } = getComponent(defaultHsUrl, defaultIsUrl, undefined, true); + + await waitFor(() => expect(container.querySelector("#mx_RegistrationForm_username")).toBeTruthy()); + // when password and confirm password fields are in separate rows there should be 4 rather than 3 + expect(container.querySelectorAll(".mx_AuthBody_fieldRow")).toHaveLength(4); + }); + }); }); }); diff --git a/test/components/views/dialogs/AccessSecretStorageDialog-test.tsx b/test/components/views/dialogs/AccessSecretStorageDialog-test.tsx index 38bc9bf342..c07808ac11 100644 --- a/test/components/views/dialogs/AccessSecretStorageDialog-test.tsx +++ b/test/components/views/dialogs/AccessSecretStorageDialog-test.tsx @@ -58,15 +58,12 @@ describe("AccessSecretStorageDialog", () => { beforeEach(() => { mockClient = getMockClientWithEventEmitter({ - keyBackupKeyFromRecoveryKey: jest.fn(), checkSecretStorageKey: jest.fn(), - isValidRecoveryKey: jest.fn(), }); }); it("Closes the dialog when the form is submitted with a valid key", async () => { mockClient.checkSecretStorageKey.mockResolvedValue(true); - mockClient.isValidRecoveryKey.mockReturnValue(true); const onFinished = jest.fn(); const checkPrivateKey = jest.fn().mockResolvedValue(true); @@ -88,8 +85,8 @@ describe("AccessSecretStorageDialog", () => { const checkPrivateKey = jest.fn().mockResolvedValue(true); renderComponent({ onFinished, checkPrivateKey }); - mockClient.keyBackupKeyFromRecoveryKey.mockImplementation(() => { - throw new Error("that's no key"); + mockClient.checkSecretStorageKey.mockImplementation(() => { + throw new Error("invalid key"); }); await enterSecurityKey(); @@ -115,7 +112,6 @@ describe("AccessSecretStorageDialog", () => { }; const checkPrivateKey = jest.fn().mockResolvedValue(false); renderComponent({ checkPrivateKey, keyInfo }); - mockClient.isValidRecoveryKey.mockReturnValue(false); await enterSecurityKey("Security Phrase"); expect(screen.getByPlaceholderText("Security Phrase")).toHaveValue(securityKey); diff --git a/test/components/views/dialogs/security/RestoreKeyBackupDialog-test.tsx b/test/components/views/dialogs/security/RestoreKeyBackupDialog-test.tsx new file mode 100644 index 0000000000..3e52b473b6 --- /dev/null +++ b/test/components/views/dialogs/security/RestoreKeyBackupDialog-test.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only + * Please see LICENSE files in the repository root for full details. + * + */ + +import React from "react"; +import { screen, render, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +// Needed to be able to mock decodeRecoveryKey +// eslint-disable-next-line no-restricted-imports +import * as recoveryKeyModule from "matrix-js-sdk/src/crypto-api/recovery-key"; + +import RestoreKeyBackupDialog from "../../../../../src/components/views/dialogs/security/RestoreKeyBackupDialog.tsx"; +import { stubClient } from "../../../../test-utils"; + +describe("", () => { + beforeEach(() => { + stubClient(); + jest.spyOn(recoveryKeyModule, "decodeRecoveryKey").mockReturnValue(new Uint8Array(32)); + }); + + it("should render", async () => { + const { asFragment } = render(); + await waitFor(() => expect(screen.getByText("Enter Security Key")).toBeInTheDocument()); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should display an error when recovery key is invalid", async () => { + jest.spyOn(recoveryKeyModule, "decodeRecoveryKey").mockImplementation(() => { + throw new Error("Invalid recovery key"); + }); + const { asFragment } = render(); + await waitFor(() => expect(screen.getByText("Enter Security Key")).toBeInTheDocument()); + + await userEvent.type(screen.getByRole("textbox"), "invalid key"); + await waitFor(() => expect(screen.getByText("👎 Not a valid Security Key")).toBeInTheDocument()); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should not raise an error when recovery is valid", async () => { + const { asFragment } = render(); + await waitFor(() => expect(screen.getByText("Enter Security Key")).toBeInTheDocument()); + + await userEvent.type(screen.getByRole("textbox"), "valid key"); + await waitFor(() => expect(screen.getByText("👍 This looks like a valid Security Key!")).toBeInTheDocument()); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/test/components/views/dialogs/security/__snapshots__/RestoreKeyBackupDialog-test.tsx.snap b/test/components/views/dialogs/security/__snapshots__/RestoreKeyBackupDialog-test.tsx.snap new file mode 100644 index 0000000000..de0bddbe33 --- /dev/null +++ b/test/components/views/dialogs/security/__snapshots__/RestoreKeyBackupDialog-test.tsx.snap @@ -0,0 +1,298 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should display an error when recovery key is invalid 1`] = ` + +
+