Use semantic headings in user settings Security (#10774)

* split SettingsSection out of SettingsTab, replace usage

* correct copyright

* use semantic headings in GeneralRoomSettingsTab

* use SettingsTab and SettingsSubsection in room settings

* fix VoipRoomSettingsTab

* use SettingsSection components in space settings

* settingssubsection text component

* use semantic headings in HelpUserSetttings tab

* use ExternalLink components for external links

* test

* strict

* lint

* semantic heading in labs settings

* semantic headings in keyboard settings tab

* semantic heading in preferencesusersettingstab

* tidying

* use new settings components in eventindexpanel

* findByTestId

* prettier

* semantic headings and style refresh for crypto settings

* e2e panel

* test cross signing panel

* strict

* more strict

* tweak

* test eventindexpanel

* strict fixes
This commit is contained in:
Kerry 2023-05-19 10:32:10 +12:00 committed by GitHub
parent 6c262fff6b
commit d9a61c093c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 721 additions and 303 deletions

View file

@ -32,13 +32,9 @@ limitations under the License.
} }
} }
.mx_CryptographyPanel_importExportButtons .mx_AccessibleButton {
margin-right: 10px;
}
.mx_CryptographyPanel_importExportButtons { .mx_CryptographyPanel_importExportButtons {
margin-bottom: 15px;
display: inline-flex; display: inline-flex;
flex-flow: wrap; flex-flow: wrap;
row-gap: 10px; row-gap: $spacing-8;
column-gap: $spacing-8;
} }

View file

@ -15,7 +15,6 @@ limitations under the License.
*/ */
.mx_SettingsTab { .mx_SettingsTab {
--SettingsTab_section-margin-bottom-preferences-labs: 30px;
--SettingsTab_heading_nth_child-margin-top: 30px; /* TODO: Use a spacing variable */ --SettingsTab_heading_nth_child-margin-top: 30px; /* TODO: Use a spacing variable */
--SettingsTab_fullWidthField-margin-inline-end: 100px; --SettingsTab_fullWidthField-margin-inline-end: 100px;
--SettingsTab_tooltip-max-width: 120px; /* So it fits in the space provided by the page */ --SettingsTab_tooltip-max-width: 120px; /* So it fits in the space provided by the page */

View file

@ -14,43 +14,36 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_SecurityUserSettingsTab_bulkOptions .mx_AccessibleButton { .mx_SecurityUserSettingsTab_bulkOptions {
margin-right: 10px; display: flex;
flex-direction: row;
column-gap: $spacing-8;
} }
.mx_SecurityUserSettingsTab_ignoredUser { .mx_SecurityUserSettingsTab_ignoredUser {
margin-bottom: 5px; margin-bottom: $spacing-4;
} }
.mx_SecurityUserSettingsTab_ignoredUser .mx_AccessibleButton { .mx_SecurityUserSettingsTab_ignoredUser .mx_AccessibleButton {
margin-right: 10px; margin-right: $spacing-8;
} }
.mx_SecurityUserSettingsTab { .mx_SecurityUserSettingsTab_warning {
.mx_SettingsTab_section { color: $alert;
.mx_AccessibleButton_kind_link { position: relative;
font-size: inherit; padding-left: 40px;
}
}
.mx_SecurityUserSettingsTab_warning { &::before {
color: $alert; mask-repeat: no-repeat;
position: relative; mask-position: 0 center;
padding-left: 40px; mask-size: $font-24px;
margin-top: 30px; position: absolute;
width: $font-24px;
&::before { height: $font-24px;
mask-repeat: no-repeat; content: "";
mask-position: 0 center; top: 0;
mask-size: $font-24px; left: 0;
position: absolute; background-color: $alert;
width: $font-24px; mask-image: url("$(res)/img/feather-customised/alert-triangle.svg");
height: $font-24px;
content: "";
top: 0;
left: 0;
background-color: $alert;
mask-image: url("$(res)/img/feather-customised/alert-triangle.svg");
}
} }
} }

View file

@ -28,6 +28,7 @@ import ConfirmDestroyCrossSigningDialog from "../dialogs/security/ConfirmDestroy
import SetupEncryptionDialog from "../dialogs/security/SetupEncryptionDialog"; import SetupEncryptionDialog from "../dialogs/security/SetupEncryptionDialog";
import { accessSecretStorage } from "../../../SecurityManager"; import { accessSecretStorage } from "../../../SecurityManager";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import { SettingsSubsectionText } from "./shared/SettingsSubsection";
interface IState { interface IState {
error?: Error; error?: Error;
@ -178,22 +179,38 @@ export default class CrossSigningPanel extends React.PureComponent<{}, IState> {
if (homeserverSupportsCrossSigning === undefined) { if (homeserverSupportsCrossSigning === undefined) {
summarisedStatus = <Spinner />; summarisedStatus = <Spinner />;
} else if (!homeserverSupportsCrossSigning) { } else if (!homeserverSupportsCrossSigning) {
summarisedStatus = <p>{_t("Your homeserver does not support cross-signing.")}</p>; summarisedStatus = (
<SettingsSubsectionText data-testid="summarised-status">
{_t("Your homeserver does not support cross-signing.")}
</SettingsSubsectionText>
);
} else if (crossSigningReady && crossSigningPrivateKeysInStorage) { } else if (crossSigningReady && crossSigningPrivateKeysInStorage) {
summarisedStatus = <p> {_t("Cross-signing is ready for use.")}</p>; summarisedStatus = (
<SettingsSubsectionText data-testid="summarised-status">
{_t("Cross-signing is ready for use.")}
</SettingsSubsectionText>
);
} else if (crossSigningReady && !crossSigningPrivateKeysInStorage) { } else if (crossSigningReady && !crossSigningPrivateKeysInStorage) {
summarisedStatus = <p> {_t("Cross-signing is ready but keys are not backed up.")}</p>; summarisedStatus = (
<SettingsSubsectionText data-testid="summarised-status">
{_t("Cross-signing is ready but keys are not backed up.")}
</SettingsSubsectionText>
);
} else if (crossSigningPrivateKeysInStorage) { } else if (crossSigningPrivateKeysInStorage) {
summarisedStatus = ( summarisedStatus = (
<p> <SettingsSubsectionText data-testid="summarised-status">
{_t( {_t(
"Your account has a cross-signing identity in secret storage, " + "Your account has a cross-signing identity in secret storage, " +
"but it is not yet trusted by this session.", "but it is not yet trusted by this session.",
)} )}
</p> </SettingsSubsectionText>
); );
} else { } else {
summarisedStatus = <p>{_t("Cross-signing is not set up.")}</p>; summarisedStatus = (
<SettingsSubsectionText data-testid="summarised-status">
{_t("Cross-signing is not set up.")}
</SettingsSubsectionText>
);
} }
const keysExistAnywhere = const keysExistAnywhere =
@ -238,7 +255,7 @@ export default class CrossSigningPanel extends React.PureComponent<{}, IState> {
} }
return ( return (
<div> <>
{summarisedStatus} {summarisedStatus}
<details> <details>
<summary>{_t("Advanced")}</summary> <summary>{_t("Advanced")}</summary>
@ -275,7 +292,7 @@ export default class CrossSigningPanel extends React.PureComponent<{}, IState> {
</details> </details>
{errorSection} {errorSection}
{actionRow} {actionRow}
</div> </>
); );
} }
} }

View file

@ -26,6 +26,7 @@ import * as FormattingUtils from "../../../utils/FormattingUtils";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import SettingsFlag from "../elements/SettingsFlag"; import SettingsFlag from "../elements/SettingsFlag";
import { SettingLevel } from "../../../settings/SettingLevel"; import { SettingLevel } from "../../../settings/SettingLevel";
import SettingsSubsection, { SettingsSubsectionText } from "./shared/SettingsSubsection";
interface IProps {} interface IProps {}
@ -72,27 +73,28 @@ export default class CryptographyPanel extends React.Component<IProps, IState> {
} }
return ( return (
<div className="mx_SettingsTab_section mx_CryptographyPanel"> <SettingsSubsection heading={_t("Cryptography")}>
<span className="mx_SettingsTab_subheading">{_t("Cryptography")}</span> <SettingsSubsectionText>
<table className="mx_SettingsTab_subsectionText mx_CryptographyPanel_sessionInfo"> <table className="mx_CryptographyPanel_sessionInfo">
<tr> <tr>
<th scope="row">{_t("Session ID:")}</th> <th scope="row">{_t("Session ID:")}</th>
<td> <td>
<code>{deviceId}</code> <code>{deviceId}</code>
</td> </td>
</tr> </tr>
<tr> <tr>
<th scope="row">{_t("Session key:")}</th> <th scope="row">{_t("Session key:")}</th>
<td> <td>
<code> <code>
<b>{identityKey}</b> <b>{identityKey}</b>
</code> </code>
</td> </td>
</tr> </tr>
</table> </table>
</SettingsSubsectionText>
{importExportButtons} {importExportButtons}
{noSendUnverifiedSetting} {noSendUnverifiedSetting}
</div> </SettingsSubsection>
); );
} }

View file

@ -20,21 +20,20 @@ import { _t } from "../../../languageHandler";
import { SettingLevel } from "../../../settings/SettingLevel"; import { SettingLevel } from "../../../settings/SettingLevel";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import SettingsFlag from "../elements/SettingsFlag"; import SettingsFlag from "../elements/SettingsFlag";
import SettingsSubsection, { SettingsSubsectionText } from "./shared/SettingsSubsection";
const SETTING_MANUALLY_VERIFY_ALL_SESSIONS = "e2ee.manuallyVerifyAllSessions"; const SETTING_MANUALLY_VERIFY_ALL_SESSIONS = "e2ee.manuallyVerifyAllSessions";
const E2eAdvancedPanel: React.FC = () => { const E2eAdvancedPanel: React.FC = () => {
return ( return (
<div className="mx_SettingsTab_section"> <SettingsSubsection heading={_t("Encryption")}>
<span className="mx_SettingsTab_subheading">{_t("Encryption")}</span>
<SettingsFlag name={SETTING_MANUALLY_VERIFY_ALL_SESSIONS} level={SettingLevel.DEVICE} /> <SettingsFlag name={SETTING_MANUALLY_VERIFY_ALL_SESSIONS} level={SettingLevel.DEVICE} />
<div className="mx_SettingsTab_subsectionText"> <SettingsSubsectionText>
{_t( {_t(
"Individually verify each session used by a user to mark it as trusted, not trusting cross-signed devices.", "Individually verify each session used by a user to mark it as trusted, not trusting cross-signed devices.",
)} )}
</div> </SettingsSubsectionText>
</div> </SettingsSubsection>
); );
}; };

View file

@ -27,6 +27,7 @@ import { SettingLevel } from "../../../settings/SettingLevel";
import SeshatResetDialog from "../dialogs/SeshatResetDialog"; import SeshatResetDialog from "../dialogs/SeshatResetDialog";
import InlineSpinner from "../elements/InlineSpinner"; import InlineSpinner from "../elements/InlineSpinner";
import ExternalLink from "../elements/ExternalLink"; import ExternalLink from "../elements/ExternalLink";
import { SettingsSubsectionText } from "./shared/SettingsSubsection";
interface IState { interface IState {
enabling: boolean; enabling: boolean;
@ -145,8 +146,8 @@ export default class EventIndexPanel extends React.Component<{}, IState> {
if (EventIndexPeg.get() !== null) { if (EventIndexPeg.get() !== null) {
eventIndexingSettings = ( eventIndexingSettings = (
<div> <>
<div className="mx_SettingsTab_subsectionText"> <SettingsSubsectionText>
{_t( {_t(
"Securely cache encrypted messages locally for them " + "Securely cache encrypted messages locally for them " +
"to appear in search results, using %(size)s to store messages from %(rooms)s rooms.", "to appear in search results, using %(size)s to store messages from %(rooms)s rooms.",
@ -158,27 +159,25 @@ export default class EventIndexPanel extends React.Component<{}, IState> {
rooms: formatCountLong(this.state.roomCount), rooms: formatCountLong(this.state.roomCount),
}, },
)} )}
</div> </SettingsSubsectionText>
<div> <AccessibleButton kind="primary" onClick={this.onManage}>
<AccessibleButton kind="primary" onClick={this.onManage}> {_t("Manage")}
{_t("Manage")} </AccessibleButton>
</AccessibleButton> </>
</div>
</div>
); );
} else if (!this.state.eventIndexingEnabled && EventIndexPeg.supportIsInstalled()) { } else if (!this.state.eventIndexingEnabled && EventIndexPeg.supportIsInstalled()) {
eventIndexingSettings = ( eventIndexingSettings = (
<div> <>
<div className="mx_SettingsTab_subsectionText"> <SettingsSubsectionText>
{_t("Securely cache encrypted messages locally for them to appear in search results.")} {_t("Securely cache encrypted messages locally for them to appear in search results.")}
</div> </SettingsSubsectionText>
<div> <div>
<AccessibleButton kind="primary" disabled={this.state.enabling} onClick={this.onEnable}> <AccessibleButton kind="primary" disabled={this.state.enabling} onClick={this.onEnable}>
{_t("Enable")} {_t("Enable")}
</AccessibleButton> </AccessibleButton>
{this.state.enabling ? <InlineSpinner /> : <div />} {this.state.enabling ? <InlineSpinner /> : <div />}
</div> </div>
</div> </>
); );
} else if (EventIndexPeg.platformHasSupport() && !EventIndexPeg.supportIsInstalled()) { } else if (EventIndexPeg.platformHasSupport() && !EventIndexPeg.supportIsInstalled()) {
const nativeLink = const nativeLink =
@ -187,7 +186,7 @@ export default class EventIndexPanel extends React.Component<{}, IState> {
"adding-seshat-for-search-in-e2e-encrypted-rooms"; "adding-seshat-for-search-in-e2e-encrypted-rooms";
eventIndexingSettings = ( eventIndexingSettings = (
<div className="mx_SettingsTab_subsectionText"> <SettingsSubsectionText>
{_t( {_t(
"%(brand)s is missing some components required for securely " + "%(brand)s is missing some components required for securely " +
"caching encrypted messages locally. If you'd like to " + "caching encrypted messages locally. If you'd like to " +
@ -204,11 +203,11 @@ export default class EventIndexPanel extends React.Component<{}, IState> {
), ),
}, },
)} )}
</div> </SettingsSubsectionText>
); );
} else if (!EventIndexPeg.platformHasSupport()) { } else if (!EventIndexPeg.platformHasSupport()) {
eventIndexingSettings = ( eventIndexingSettings = (
<div className="mx_SettingsTab_subsectionText"> <SettingsSubsectionText>
{_t( {_t(
"%(brand)s can't securely cache encrypted messages locally " + "%(brand)s can't securely cache encrypted messages locally " +
"while running in a web browser. Use <desktopLink>%(brand)s Desktop</desktopLink> " + "while running in a web browser. Use <desktopLink>%(brand)s Desktop</desktopLink> " +
@ -228,24 +227,28 @@ export default class EventIndexPanel extends React.Component<{}, IState> {
), ),
}, },
)} )}
</div> </SettingsSubsectionText>
); );
} else { } else {
eventIndexingSettings = ( eventIndexingSettings = (
<div className="mx_SettingsTab_subsectionText"> <>
<p>{this.state.enabling ? <InlineSpinner /> : _t("Message search initialisation failed")}</p> <SettingsSubsectionText>
{this.state.enabling ? <InlineSpinner /> : _t("Message search initialisation failed")}
</SettingsSubsectionText>
{EventIndexPeg.error && ( {EventIndexPeg.error && (
<details> <SettingsSubsectionText>
<summary>{_t("Advanced")}</summary> <details>
<code>{EventIndexPeg.error.message}</code> <summary>{_t("Advanced")}</summary>
<p> <code>{EventIndexPeg.error.message}</code>
<AccessibleButton key="delete" kind="danger" onClick={this.confirmEventStoreReset}> <p>
{_t("Reset")} <AccessibleButton key="delete" kind="danger" onClick={this.confirmEventStoreReset}>
</AccessibleButton> {_t("Reset")}
</p> </AccessibleButton>
</details> </p>
</details>
</SettingsSubsectionText>
)} )}
</div> </>
); );
} }

View file

@ -31,6 +31,7 @@ import AccessibleButton from "../elements/AccessibleButton";
import QuestionDialog from "../dialogs/QuestionDialog"; import QuestionDialog from "../dialogs/QuestionDialog";
import RestoreKeyBackupDialog from "../dialogs/security/RestoreKeyBackupDialog"; import RestoreKeyBackupDialog from "../dialogs/security/RestoreKeyBackupDialog";
import { accessSecretStorage } from "../../../SecurityManager"; import { accessSecretStorage } from "../../../SecurityManager";
import { SettingsSubsectionText } from "./shared/SettingsSubsection";
interface IState { interface IState {
loading: boolean; loading: boolean;
@ -247,7 +248,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
} else { } else {
statusDescription = ( statusDescription = (
<> <>
<p> <SettingsSubsectionText>
{_t( {_t(
"This session is <b>not backing up your keys</b>, " + "This session is <b>not backing up your keys</b>, " +
"but you do have an existing backup you can restore from " + "but you do have an existing backup you can restore from " +
@ -255,13 +256,13 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
{}, {},
{ b: (sub) => <b>{sub}</b> }, { b: (sub) => <b>{sub}</b> },
)} )}
</p> </SettingsSubsectionText>
<p> <SettingsSubsectionText>
{_t( {_t(
"Connect this session to key backup before signing out to avoid " + "Connect this session to key backup before signing out to avoid " +
"losing any keys that may only be on this session.", "losing any keys that may only be on this session.",
)} )}
</p> </SettingsSubsectionText>
</> </>
); );
restoreButtonCaption = _t("Connect this session to Key Backup"); restoreButtonCaption = _t("Connect this session to Key Backup");
@ -425,14 +426,16 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
} else { } else {
statusDescription = ( statusDescription = (
<> <>
<p> <SettingsSubsectionText>
{_t( {_t(
"Your keys are <b>not being backed up from this session</b>.", "Your keys are <b>not being backed up from this session</b>.",
{}, {},
{ b: (sub) => <b>{sub}</b> }, { b: (sub) => <b>{sub}</b> },
)} )}
</p> </SettingsSubsectionText>
<p>{_t("Back up your keys before signing out to avoid losing them.")}</p> <SettingsSubsectionText>
{_t("Back up your keys before signing out to avoid losing them.")}
</SettingsSubsectionText>
</> </>
); );
actions.push( actions.push(
@ -466,14 +469,14 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
} }
return ( return (
<div> <>
<p> <SettingsSubsectionText>
{_t( {_t(
"Back up your encryption keys with your account data in case you " + "Back up your encryption keys with your account data in case you " +
"lose access to your sessions. Your keys will be secured with a " + "lose access to your sessions. Your keys will be secured with a " +
"unique Security Key.", "unique Security Key.",
)} )}
</p> </SettingsSubsectionText>
{statusDescription} {statusDescription}
<details> <details>
<summary>{_t("Advanced")}</summary> <summary>{_t("Advanced")}</summary>
@ -502,7 +505,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
{extraDetails} {extraDetails}
</details> </details>
{actionRow} {actionRow}
</div> </>
); );
} }
} }

View file

@ -477,10 +477,6 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
{historySection} {historySection}
</SettingsSection> </SettingsSection>
</SettingsTab> </SettingsTab>
// <div className="mx_SettingsTab mx_SecurityRoomSettingsTab">
// <div className="mx_SettingsTab_heading">{_t("Security & Privacy")}</div>
// </div>
); );
} }
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2019 - 2022 The Matrix.org Foundation C.I.C. Copyright 2019 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -41,6 +41,9 @@ import { privateShouldBeEncrypted } from "../../../../../utils/rooms";
import LoginWithQR, { Mode } from "../../../auth/LoginWithQR"; import LoginWithQR, { Mode } from "../../../auth/LoginWithQR";
import LoginWithQRSection from "../../devices/LoginWithQRSection"; import LoginWithQRSection from "../../devices/LoginWithQRSection";
import type { IServerVersions } from "matrix-js-sdk/src/matrix"; import type { IServerVersions } from "matrix-js-sdk/src/matrix";
import SettingsTab from "../SettingsTab";
import { SettingsSection } from "../../shared/SettingsSection";
import SettingsSubsection, { SettingsSubsectionText } from "../../shared/SettingsSubsection";
interface IIgnoredUserProps { interface IIgnoredUserProps {
userId: string; userId: string;
@ -245,10 +248,9 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
}); });
return ( return (
<div className="mx_SettingsTab_section"> <SettingsSubsection heading={_t("Ignored users")}>
<span className="mx_SettingsTab_subheading">{_t("Ignored users")}</span> <SettingsSubsectionText>{userIds}</SettingsSubsectionText>
<div className="mx_SettingsTab_subsectionText">{userIds}</div> </SettingsSubsection>
</div>
); );
} }
@ -260,24 +262,25 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
} }
return ( return (
<div className="mx_SettingsTab_section mx_SecurityUserSettingsTab_bulkOptions"> <SettingsSubsection heading={_t("Bulk options")}>
<span className="mx_SettingsTab_subheading">{_t("Bulk options")}</span> <div className="mx_SecurityUserSettingsTab_bulkOptions">
<AccessibleButton <AccessibleButton
onClick={this.onAcceptAllInvitesClicked} onClick={this.onAcceptAllInvitesClicked}
kind="primary" kind="primary"
disabled={this.state.managingInvites} disabled={this.state.managingInvites}
> >
{_t("Accept all %(invitedRooms)s invites", { invitedRooms: invitedRoomIds.size })} {_t("Accept all %(invitedRooms)s invites", { invitedRooms: invitedRoomIds.size })}
</AccessibleButton> </AccessibleButton>
<AccessibleButton <AccessibleButton
onClick={this.onRejectAllInvitesClicked} onClick={this.onRejectAllInvitesClicked}
kind="danger" kind="danger"
disabled={this.state.managingInvites} disabled={this.state.managingInvites}
> >
{_t("Reject all %(invitedRooms)s invites", { invitedRooms: invitedRoomIds.size })} {_t("Reject all %(invitedRooms)s invites", { invitedRooms: invitedRoomIds.size })}
</AccessibleButton> </AccessibleButton>
{this.state.managingInvites ? <InlineSpinner /> : <div />} {this.state.managingInvites ? <InlineSpinner /> : <div />}
</div> </div>
</SettingsSubsection>
); );
} }
@ -291,19 +294,15 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
public render(): React.ReactNode { public render(): React.ReactNode {
const secureBackup = ( const secureBackup = (
<div className="mx_SettingsTab_section"> <SettingsSubsection heading={_t("Secure Backup")}>
<span className="mx_SettingsTab_subheading">{_t("Secure Backup")}</span> <SecureBackupPanel />
<div className="mx_SettingsTab_subsectionText"> </SettingsSubsection>
<SecureBackupPanel />
</div>
</div>
); );
const eventIndex = ( const eventIndex = (
<div className="mx_SettingsTab_section"> <SettingsSubsection heading={_t("Message search")}>
<span className="mx_SettingsTab_subheading">{_t("Message search")}</span>
<EventIndexPanel /> <EventIndexPanel />
</div> </SettingsSubsection>
); );
// XXX: There's no such panel in the current cross-signing designs, but // XXX: There's no such panel in the current cross-signing designs, but
@ -311,12 +310,9 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
// in having advanced details here once all flows are implemented, we // in having advanced details here once all flows are implemented, we
// can remove this. // can remove this.
const crossSigning = ( const crossSigning = (
<div className="mx_SettingsTab_section"> <SettingsSubsection heading={_t("Cross-signing")}>
<span className="mx_SettingsTab_subheading">{_t("Cross-signing")}</span> <CrossSigningPanel />
<div className="mx_SettingsTab_subsectionText"> </SettingsSubsection>
<CrossSigningPanel />
</div>
</div>
); );
let warning; let warning;
@ -340,28 +336,24 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
}); });
}; };
privacySection = ( privacySection = (
<React.Fragment> <SettingsSection heading={_t("Privacy")}>
<div className="mx_SettingsTab_heading">{_t("Privacy")}</div> <SettingsSubsection
<div className="mx_SettingsTab_section"> heading={_t("Analytics")}
<span className="mx_SettingsTab_subheading">{_t("Analytics")}</span> description={_t(
<div className="mx_SettingsTab_subsectionText"> "Share anonymous data to help us identify issues. Nothing personal. No third parties.",
<p> )}
{_t( >
"Share anonymous data to help us identify issues. Nothing personal. " + <AccessibleButton kind="link" onClick={onClickAnalyticsLearnMore}>
"No third parties.", {_t("Learn more")}
)} </AccessibleButton>
</p>
<AccessibleButton kind="link" onClick={onClickAnalyticsLearnMore}>
{_t("Learn more")}
</AccessibleButton>
</div>
{PosthogAnalytics.instance.isEnabled() && ( {PosthogAnalytics.instance.isEnabled() && (
<SettingsFlag name="pseudonymousAnalyticsOptIn" level={SettingLevel.ACCOUNT} /> <SettingsFlag name="pseudonymousAnalyticsOptIn" level={SettingLevel.ACCOUNT} />
)} )}
<span className="mx_SettingsTab_subheading">{_t("Sessions")}</span> </SettingsSubsection>
<SettingsSubsection heading={_t("Sessions")}>
<SettingsFlag name="deviceClientInformationOptIn" level={SettingLevel.ACCOUNT} /> <SettingsFlag name="deviceClientInformationOptIn" level={SettingLevel.ACCOUNT} />
</div> </SettingsSubsection>
</React.Fragment> </SettingsSection>
); );
} }
@ -373,67 +365,60 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
// only show the section if there's something to show // only show the section if there's something to show
if (ignoreUsersPanel || invitesPanel || e2ePanel) { if (ignoreUsersPanel || invitesPanel || e2ePanel) {
advancedSection = ( advancedSection = (
<> <SettingsSection heading={_t("Advanced")}>
<div className="mx_SettingsTab_heading">{_t("Advanced")}</div> {ignoreUsersPanel}
<div className="mx_SettingsTab_section"> {invitesPanel}
{ignoreUsersPanel} {e2ePanel}
{invitesPanel} </SettingsSection>
{e2ePanel}
</div>
</>
); );
} }
} }
const useNewSessionManager = SettingsStore.getValue("feature_new_device_manager"); const useNewSessionManager = SettingsStore.getValue("feature_new_device_manager");
const devicesSection = useNewSessionManager ? null : ( const devicesSection = useNewSessionManager ? null : (
<> <SettingsSection heading={_t("Where you're signed in")} data-testid="devices-section">
<div className="mx_SettingsTab_heading">{_t("Where you're signed in")}</div> <SettingsSubsectionText>
<div className="mx_SettingsTab_section" data-testid="devices-section"> {_t(
<span className="mx_SettingsTab_subsectionText"> "Manage your signed-in devices below. " +
{_t( "A device's name is visible to people you communicate with.",
"Manage your signed-in devices below. " + )}
"A device's name is visible to people you communicate with.", </SettingsSubsectionText>
)} <DevicesPanel />
</span>
<DevicesPanel />
</div>
<LoginWithQRSection <LoginWithQRSection
onShowQr={this.onShowQRClicked} onShowQr={this.onShowQRClicked}
versions={this.state.versions} versions={this.state.versions}
capabilities={this.state.capabilities} capabilities={this.state.capabilities}
/> />
</> </SettingsSection>
); );
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
if (this.state.showLoginWithQR) { if (this.state.showLoginWithQR) {
return ( return (
<div className="mx_SettingsTab mx_SecurityUserSettingsTab"> <SettingsTab>
<LoginWithQR <LoginWithQR
onFinished={this.onLoginWithQRFinished} onFinished={this.onLoginWithQRFinished}
mode={this.state.showLoginWithQR} mode={this.state.showLoginWithQR}
client={client} client={client}
/> />
</div> </SettingsTab>
); );
} }
return ( return (
<div className="mx_SettingsTab mx_SecurityUserSettingsTab"> <SettingsTab>
{warning} {warning}
{devicesSection} {devicesSection}
<div className="mx_SettingsTab_heading">{_t("Encryption")}</div> <SettingsSection heading={_t("Encryption")}>
<div className="mx_SettingsTab_section">
{secureBackup} {secureBackup}
{eventIndex} {eventIndex}
{crossSigning} {crossSigning}
<CryptographyPanel /> <CryptographyPanel />
</div> </SettingsSection>
{privacySection} {privacySection}
{advancedSection} {advancedSection}
</div> </SettingsTab>
); );
} }
} }

View file

@ -259,6 +259,7 @@ const SessionManagerTab: React.FC = () => {
`from any session that you don't recognize or use anymore.`, `from any session that you don't recognize or use anymore.`,
)} )}
data-testid="other-sessions-section" data-testid="other-sessions-section"
stretchContent
> >
<FilteredDeviceList <FilteredDeviceList
devices={otherDevices} devices={otherDevices}

View file

@ -0,0 +1,113 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { mocked } from "jest-mock";
import CrossSigningPanel from "../../../../src/components/views/settings/CrossSigningPanel";
import {
flushPromises,
getMockClientWithEventEmitter,
mockClientMethodsCrypto,
mockClientMethodsUser,
} from "../../../test-utils";
describe("<CrossSigningPanel />", () => {
const userId = "@alice:server.org";
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
...mockClientMethodsCrypto(),
doesServerSupportUnstableFeature: jest.fn(),
});
const getComponent = () => render(<CrossSigningPanel />);
beforeEach(() => {
mockClient.doesServerSupportUnstableFeature.mockResolvedValue(true);
mockClient.isCrossSigningReady.mockResolvedValue(false);
mocked(mockClient.crypto!.crossSigningInfo).isStoredInSecretStorage.mockClear().mockResolvedValue(null);
});
it("should render a spinner while loading", () => {
getComponent();
expect(screen.getByRole("progressbar")).toBeInTheDocument();
});
it("should render when homeserver does not support cross-signing", async () => {
mockClient.doesServerSupportUnstableFeature.mockResolvedValue(false);
getComponent();
await flushPromises();
expect(screen.getByText("Your homeserver does not support cross-signing.")).toBeInTheDocument();
});
describe("when cross signing is ready", () => {
beforeEach(() => {
mockClient.isCrossSigningReady.mockResolvedValue(true);
});
it("should render when keys are not backed up", async () => {
getComponent();
await flushPromises();
expect(screen.getByTestId("summarised-status").innerHTML).toEqual(
"⚠️ Cross-signing is ready but keys are not backed up.",
);
expect(screen.getByText("Cross-signing private keys:").parentElement!).toMatchSnapshot();
});
it("should render when keys are backed up", async () => {
mocked(mockClient.crypto!.crossSigningInfo).isStoredInSecretStorage.mockResolvedValue({ test: {} });
getComponent();
await flushPromises();
expect(screen.getByTestId("summarised-status").innerHTML).toEqual("✅ Cross-signing is ready for use.");
expect(screen.getByText("Cross-signing private keys:").parentElement!).toMatchSnapshot();
expect(mockClient.crypto!.crossSigningInfo.isStoredInSecretStorage).toHaveBeenCalledWith(
mockClient.crypto!.secretStorage,
);
});
});
describe("when cross signing is not ready", () => {
beforeEach(() => {
mockClient.isCrossSigningReady.mockResolvedValue(false);
});
it("should render when keys are not backed up", async () => {
getComponent();
await flushPromises();
expect(screen.getByTestId("summarised-status").innerHTML).toEqual("Cross-signing is not set up.");
});
it("should render when keys are backed up", async () => {
mocked(mockClient.crypto!.crossSigningInfo).isStoredInSecretStorage.mockResolvedValue({ test: {} });
getComponent();
await flushPromises();
expect(screen.getByTestId("summarised-status").innerHTML).toEqual(
"Your account has a cross-signing identity in secret storage, but it is not yet trusted by this session.",
);
expect(screen.getByText("Cross-signing private keys:").parentElement!).toMatchSnapshot();
expect(mockClient.crypto!.crossSigningInfo.isStoredInSecretStorage).toHaveBeenCalledWith(
mockClient.crypto!.secretStorage,
);
});
});
});

View file

@ -0,0 +1,201 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { defer, IDeferred } from "matrix-js-sdk/src/utils";
import EventIndexPanel from "../../../../src/components/views/settings/EventIndexPanel";
import EventIndexPeg from "../../../../src/indexing/EventIndexPeg";
import EventIndex from "../../../../src/indexing/EventIndex";
import { clearAllModals, flushPromises, getMockClientWithEventEmitter } from "../../../test-utils";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
describe("<EventIndexPanel />", () => {
getMockClientWithEventEmitter({
getRooms: jest.fn().mockReturnValue([]),
});
const getComponent = () => render(<EventIndexPanel />);
beforeEach(() => {
jest.spyOn(EventIndexPeg, "get").mockRestore();
jest.spyOn(EventIndexPeg, "platformHasSupport").mockReturnValue(false);
jest.spyOn(EventIndexPeg, "supportIsInstalled").mockReturnValue(false);
jest.spyOn(EventIndexPeg, "initEventIndex").mockClear().mockResolvedValue(true);
jest.spyOn(EventIndexPeg, "deleteEventIndex").mockClear();
jest.spyOn(SettingsStore, "getValueAt").mockReturnValue(false);
jest.spyOn(SettingsStore, "setValue").mockClear();
// @ts-ignore private property
EventIndexPeg.error = null;
});
afterEach(async () => {
await clearAllModals();
});
describe("when event index is initialised", () => {
it("renders event index information", () => {
jest.spyOn(EventIndexPeg, "get").mockReturnValue(new EventIndex());
const { container } = getComponent();
expect(container).toMatchSnapshot();
});
it("opens event index management dialog", async () => {
jest.spyOn(EventIndexPeg, "get").mockReturnValue(new EventIndex());
getComponent();
fireEvent.click(screen.getByText("Manage"));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("Message search")).toBeInTheDocument();
// close the modal
fireEvent.click(within(dialog).getByText("Done"));
});
});
describe("when event indexing is fully supported and enabled but not initialised", () => {
beforeEach(() => {
jest.spyOn(EventIndexPeg, "supportIsInstalled").mockReturnValue(true);
jest.spyOn(EventIndexPeg, "platformHasSupport").mockReturnValue(true);
jest.spyOn(SettingsStore, "getValueAt").mockReturnValue(true);
// @ts-ignore private property
EventIndexPeg.error = { message: "Test error message" };
});
it("displays an error when no event index is found and enabling not in progress", () => {
getComponent();
expect(screen.getByText("Message search initialisation failed")).toBeInTheDocument();
});
it("displays an error from the event index", () => {
getComponent();
expect(screen.getByText("Test error message")).toBeInTheDocument();
});
it("asks for confirmation when resetting seshat", async () => {
getComponent();
fireEvent.click(screen.getByText("Reset"));
// wait for reset modal to open
await screen.findByText("Reset event store?");
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByText("Reset event store?")).toBeInTheDocument();
fireEvent.click(within(dialog).getByText("Cancel"));
// didn't reset
expect(SettingsStore.setValue).not.toHaveBeenCalled();
expect(EventIndexPeg.deleteEventIndex).not.toHaveBeenCalled();
});
it("resets seshat", async () => {
getComponent();
fireEvent.click(screen.getByText("Reset"));
// wait for reset modal to open
await screen.findByText("Reset event store?");
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByText("Reset event store"));
await flushPromises();
expect(SettingsStore.setValue).toHaveBeenCalledWith(
"enableEventIndexing",
null,
SettingLevel.DEVICE,
false,
);
expect(EventIndexPeg.deleteEventIndex).toHaveBeenCalled();
await clearAllModals();
});
});
describe("when event indexing is supported but not enabled", () => {
it("renders enable text", () => {
jest.spyOn(EventIndexPeg, "supportIsInstalled").mockReturnValue(true);
getComponent();
expect(
screen.getByText("Securely cache encrypted messages locally for them to appear in search results."),
).toBeInTheDocument();
});
it("enables event indexing on enable button click", async () => {
jest.spyOn(EventIndexPeg, "supportIsInstalled").mockReturnValue(true);
let deferredInitEventIndex: IDeferred<boolean> | undefined;
jest.spyOn(EventIndexPeg, "initEventIndex").mockImplementation(() => {
deferredInitEventIndex = defer<boolean>();
return deferredInitEventIndex.promise;
});
getComponent();
fireEvent.click(screen.getByText("Enable"));
await flushPromises();
// spinner shown while enabling
expect(screen.getByLabelText("Loading…")).toBeInTheDocument();
// add an event indx to the peg and resolve the init promise
jest.spyOn(EventIndexPeg, "get").mockReturnValue(new EventIndex());
expect(EventIndexPeg.initEventIndex).toHaveBeenCalled();
deferredInitEventIndex!.resolve(true);
await flushPromises();
expect(SettingsStore.setValue).toHaveBeenCalledWith("enableEventIndexing", null, SettingLevel.DEVICE, true);
// message for enabled event index
expect(
screen.getByText(
"Securely cache encrypted messages locally for them to appear in search results, using 0 Bytes to store messages from 0 rooms.",
),
).toBeInTheDocument();
});
});
describe("when event indexing is supported but not installed", () => {
it("renders link to install seshat", () => {
jest.spyOn(EventIndexPeg, "supportIsInstalled").mockReturnValue(false);
jest.spyOn(EventIndexPeg, "platformHasSupport").mockReturnValue(true);
const { container } = getComponent();
expect(container).toMatchSnapshot();
});
});
describe("when event indexing is not supported", () => {
it("renders link to download a desktop client", () => {
jest.spyOn(EventIndexPeg, "platformHasSupport").mockReturnValue(false);
const { container } = getComponent();
expect(container).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,40 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<CrossSigningPanel /> when cross signing is not ready should render when keys are backed up 1`] = `
<tr>
<th
scope="row"
>
Cross-signing private keys:
</th>
<td>
in secret storage
</td>
</tr>
`;
exports[`<CrossSigningPanel /> when cross signing is ready should render when keys are backed up 1`] = `
<tr>
<th
scope="row"
>
Cross-signing private keys:
</th>
<td>
in secret storage
</td>
</tr>
`;
exports[`<CrossSigningPanel /> when cross signing is ready should render when keys are not backed up 1`] = `
<tr>
<th
scope="row"
>
Cross-signing private keys:
</th>
<td>
not found in storage
</td>
</tr>
`;

View file

@ -0,0 +1,66 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<EventIndexPanel /> when event index is initialised renders event index information 1`] = `
<div>
<div
class="mx_SettingsSubsection_text"
>
Securely cache encrypted messages locally for them to appear in search results, using 0 Bytes to store messages from 0 rooms.
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Manage
</div>
</div>
`;
exports[`<EventIndexPanel /> when event indexing is not supported renders link to download a desktop client 1`] = `
<div>
<div
class="mx_SettingsSubsection_text"
>
<span>
Element can't securely cache encrypted messages locally while running in a web browser. Use
<a
class="mx_ExternalLink"
href="https://element.io/get-started"
rel="noreferrer noopener"
target="_blank"
>
Element Desktop
<i
class="mx_ExternalLink_icon"
/>
</a>
for encrypted messages to appear in search results.
</span>
</div>
</div>
`;
exports[`<EventIndexPanel /> when event indexing is supported but not installed renders link to install seshat 1`] = `
<div>
<div
class="mx_SettingsSubsection_text"
>
<span>
Element is missing some components required for securely caching encrypted messages locally. If you'd like to experiment with this feature, build a custom Element Desktop with
<a
class="mx_ExternalLink"
href="https://github.com/vector-im/element-desktop/blob/develop/docs/native-node-modules.md#adding-seshat-for-search-in-e2e-encrypted-rooms"
rel="noreferrer noopener"
target="_blank"
>
search components added
<i
class="mx_ExternalLink_icon"
/>
</a>
.
</span>
</div>
</div>
`;

View file

@ -2,114 +2,118 @@
exports[`<SecureBackupPanel /> suggests connecting session to key backup when backup exists 1`] = ` exports[`<SecureBackupPanel /> suggests connecting session to key backup when backup exists 1`] = `
<div> <div>
<div> <div
<p> class="mx_SettingsSubsection_text"
Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key. >
</p> Back up your encryption keys with your account data in case you lose access to your sessions. Your keys will be secured with a unique Security Key.
<p> </div>
<span> <div
This session is class="mx_SettingsSubsection_text"
<b> >
not backing up your keys <span>
</b> This session is
, but you do have an existing backup you can restore from and add to going forward. <b>
</span> not backing up your keys
</p> </b>
<p> , but you do have an existing backup you can restore from and add to going forward.
Connect this session to key backup before signing out to avoid losing any keys that may only be on this session. </span>
</p> </div>
<details> <div
<summary> class="mx_SettingsSubsection_text"
Advanced >
</summary> Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.
<table </div>
class="mx_SecureBackupPanel_statusList" <details>
> <summary>
<tr> Advanced
<th </summary>
scope="row" <table
> class="mx_SecureBackupPanel_statusList"
Backup key stored:
</th>
<td>
not stored
</td>
</tr>
<tr>
<th
scope="row"
>
Backup key cached:
</th>
<td>
not found locally
</td>
</tr>
<tr>
<th
scope="row"
>
Secret storage public key:
</th>
<td>
not found
</td>
</tr>
<tr>
<th
scope="row"
>
Secret storage:
</th>
<td>
not ready
</td>
</tr>
<tr>
<th
scope="row"
>
Backup version:
</th>
<td>
1
</td>
</tr>
<tr>
<th
scope="row"
>
Algorithm:
</th>
<td>
test
</td>
</tr>
</table>
<div>
Backup is not signed by any of your sessions
</div>
<div />
</details>
<div
class="mx_SecureBackupPanel_buttonRow"
> >
<div <tr>
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary" <th
role="button" scope="row"
tabindex="0" >
> Backup key stored:
Connect this session to Key Backup </th>
</div> <td>
<div not stored
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger" </td>
role="button" </tr>
tabindex="0" <tr>
> <th
Delete Backup scope="row"
</div> >
Backup key cached:
</th>
<td>
not found locally
</td>
</tr>
<tr>
<th
scope="row"
>
Secret storage public key:
</th>
<td>
not found
</td>
</tr>
<tr>
<th
scope="row"
>
Secret storage:
</th>
<td>
not ready
</td>
</tr>
<tr>
<th
scope="row"
>
Backup version:
</th>
<td>
1
</td>
</tr>
<tr>
<th
scope="row"
>
Algorithm:
</th>
<td>
test
</td>
</tr>
</table>
<div>
Backup is not signed by any of your sessions
</div>
<div />
</details>
<div
class="mx_SecureBackupPanel_buttonRow"
>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="0"
>
Connect this session to Key Backup
</div>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_danger"
role="button"
tabindex="0"
>
Delete Backup
</div> </div>
</div> </div>
</div> </div>