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:
parent
6c262fff6b
commit
d9a61c093c
16 changed files with 721 additions and 303 deletions
113
test/components/views/settings/CrossSigningPanel-test.tsx
Normal file
113
test/components/views/settings/CrossSigningPanel-test.tsx
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
201
test/components/views/settings/EventIndexPanel-test.tsx
Normal file
201
test/components/views/settings/EventIndexPanel-test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
`;
|
|
@ -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>
|
||||
`;
|
|
@ -2,114 +2,118 @@
|
|||
|
||||
exports[`<SecureBackupPanel /> suggests connecting session to key backup when backup exists 1`] = `
|
||||
<div>
|
||||
<div>
|
||||
<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>
|
||||
<p>
|
||||
<span>
|
||||
This session is
|
||||
<b>
|
||||
not backing up your keys
|
||||
</b>
|
||||
, but you do have an existing backup you can restore from and add to going forward.
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.
|
||||
</p>
|
||||
<details>
|
||||
<summary>
|
||||
Advanced
|
||||
</summary>
|
||||
<table
|
||||
class="mx_SecureBackupPanel_statusList"
|
||||
>
|
||||
<tr>
|
||||
<th
|
||||
scope="row"
|
||||
>
|
||||
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
|
||||
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.
|
||||
</div>
|
||||
<div
|
||||
class="mx_SettingsSubsection_text"
|
||||
>
|
||||
<span>
|
||||
This session is
|
||||
<b>
|
||||
not backing up your keys
|
||||
</b>
|
||||
, but you do have an existing backup you can restore from and add to going forward.
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="mx_SettingsSubsection_text"
|
||||
>
|
||||
Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.
|
||||
</div>
|
||||
<details>
|
||||
<summary>
|
||||
Advanced
|
||||
</summary>
|
||||
<table
|
||||
class="mx_SecureBackupPanel_statusList"
|
||||
>
|
||||
<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>
|
||||
<tr>
|
||||
<th
|
||||
scope="row"
|
||||
>
|
||||
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
|
||||
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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue