Merge 1811aa6c98
into 8dff758153
This commit is contained in:
commit
0ec5eb4de8
18 changed files with 830 additions and 17 deletions
|
@ -596,7 +596,9 @@ legend {
|
|||
.mx_Dialog
|
||||
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
|
||||
.mx_UserProfileSettings button
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button),
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not(
|
||||
.mx_EncryptionUserSettingsTab button
|
||||
),
|
||||
.mx_Dialog input[type="submit"],
|
||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton),
|
||||
.mx_Dialog_buttons input[type="submit"] {
|
||||
|
@ -616,8 +618,8 @@ legend {
|
|||
.mx_Dialog
|
||||
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
|
||||
.mx_UserProfileSettings button
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(
|
||||
.mx_ShareDialog button
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not(
|
||||
.mx_EncryptionUserSettingsTab button
|
||||
):last-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
@ -625,7 +627,9 @@ legend {
|
|||
.mx_Dialog
|
||||
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
|
||||
.mx_UserProfileSettings button
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):focus,
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not(
|
||||
.mx_EncryptionUserSettingsTab button
|
||||
):focus,
|
||||
.mx_Dialog input[type="submit"]:focus,
|
||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus,
|
||||
.mx_Dialog_buttons input[type="submit"]:focus {
|
||||
|
@ -637,7 +641,9 @@ legend {
|
|||
.mx_Dialog_buttons
|
||||
button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(
|
||||
.mx_UserProfileSettings button
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button),
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not(
|
||||
.mx_EncryptionUserSettingsTab button
|
||||
),
|
||||
.mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
|
||||
color: var(--cpd-color-text-on-solid-primary);
|
||||
background-color: var(--cpd-color-bg-action-primary-rest);
|
||||
|
@ -650,7 +656,7 @@ legend {
|
|||
.mx_Dialog_buttons
|
||||
button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not(
|
||||
.mx_ThemeChoicePanel_CustomTheme button
|
||||
):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button),
|
||||
):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not(.mx_EncryptionUserSettingsTab button),
|
||||
.mx_Dialog_buttons input[type="submit"].danger {
|
||||
background-color: var(--cpd-color-bg-critical-primary);
|
||||
border: solid 1px var(--cpd-color-bg-critical-primary);
|
||||
|
@ -666,7 +672,9 @@ legend {
|
|||
.mx_Dialog
|
||||
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
|
||||
.mx_UserProfileSettings button
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):disabled,
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not(
|
||||
.mx_EncryptionUserSettingsTab button
|
||||
):disabled,
|
||||
.mx_Dialog input[type="submit"]:disabled,
|
||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled,
|
||||
.mx_Dialog_buttons input[type="submit"]:disabled {
|
||||
|
|
|
@ -346,10 +346,14 @@
|
|||
@import "./views/settings/_SetIdServer.pcss";
|
||||
@import "./views/settings/_SetIntegrationManager.pcss";
|
||||
@import "./views/settings/_SettingsFieldset.pcss";
|
||||
@import "./views/settings/_SettingsHeader.pcss";
|
||||
@import "./views/settings/_SpellCheckLanguages.pcss";
|
||||
@import "./views/settings/_ThemeChoicePanel.pcss";
|
||||
@import "./views/settings/_UpdateCheckButton.pcss";
|
||||
@import "./views/settings/_UserProfileSettings.pcss";
|
||||
@import "./views/settings/encryption/_ChangeRecoveryKey.pcss";
|
||||
@import "./views/settings/encryption/_RecoveryPanel.pcss";
|
||||
@import "./views/settings/encryption/_EncryptionCard.pcss";
|
||||
@import "./views/settings/tabs/_SettingsBanner.pcss";
|
||||
@import "./views/settings/tabs/_SettingsIndent.pcss";
|
||||
@import "./views/settings/tabs/_SettingsSection.pcss";
|
||||
|
|
19
res/css/views/settings/_SettingsHeader.pcss
Normal file
19
res/css/views/settings/_SettingsHeader.pcss
Normal file
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.mx_SettingsHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-2x);
|
||||
/* Override margin from common.pcss */
|
||||
margin: 0;
|
||||
|
||||
> span {
|
||||
font: var(--cpd-font-body-sm-medium);
|
||||
color: var(--cpd-color-text-action-accent);
|
||||
}
|
||||
}
|
73
res/css/views/settings/encryption/_ChangeRecoveryKey.pcss
Normal file
73
res/css/views/settings/encryption/_ChangeRecoveryKey.pcss
Normal file
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.mx_ChangeRecoveryKey {
|
||||
.mx_WarningPanel_description {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mx_ChangeRecoveryKey_Form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-8x);
|
||||
|
||||
.mx_ChangeRecoveryKey_footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-4x);
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_KeyPanel {
|
||||
display: grid;
|
||||
grid-template:
|
||||
"header button" auto
|
||||
"content button" auto;
|
||||
|
||||
column-gap: var(--cpd-space-3x);
|
||||
row-gap: var(--cpd-space-1x);
|
||||
align-items: center;
|
||||
|
||||
> span {
|
||||
grid-area: header;
|
||||
}
|
||||
|
||||
> div {
|
||||
grid-area: content;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-2x);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
|
||||
.mx_KeyPanel_key {
|
||||
border-radius: var(--cpd-space-2x);
|
||||
padding: var(--cpd-space-3x) var(--cpd-space-4x);
|
||||
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
> button {
|
||||
margin: 0 var(--cpd-space-1x);
|
||||
grid-area: button;
|
||||
color: var(--cpd-color-icon-secondary-alpha);
|
||||
}
|
||||
}
|
||||
|
||||
.mx_KeyForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-8x);
|
||||
}
|
||||
|
||||
.mx_ChangeRecoveryKey_footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-4x);
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
33
res/css/views/settings/encryption/_EncryptionCard.pcss
Normal file
33
res/css/views/settings/encryption/_EncryptionCard.pcss
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.mx_EncryptionCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-8x);
|
||||
padding: var(--cpd-space-10x);
|
||||
border-radius: var(--cpd-space-4x);
|
||||
/* From figma */
|
||||
box-shadow: 0 1.2px 2.4px 0 rgba(27, 29, 34, 0.15);
|
||||
border: 1px solid var(--cpd-color-gray-400);
|
||||
|
||||
.mx_EncryptionCard_header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-4x);
|
||||
align-items: center;
|
||||
|
||||
> h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
> span {
|
||||
color: var(--cpd-color-text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
22
res/css/views/settings/encryption/_RecoveryPanel.pcss
Normal file
22
res/css/views/settings/encryption/_RecoveryPanel.pcss
Normal file
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
.mx_RecoveryPanel {
|
||||
.mx_RecoveryPanel_Subheader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-2x);
|
||||
|
||||
> span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--cpd-space-2x);
|
||||
font: var(--cpd-font-body-sm-medium);
|
||||
color: var(--cpd-color-text-success-primary);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -15,6 +15,20 @@ Please see LICENSE files in the repository root for full details.
|
|||
a {
|
||||
color: $links;
|
||||
}
|
||||
|
||||
&.mx_SettingsSection_newUi {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-6x);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.mx_SettingsSection_header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-3x);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.mx_SettingsSection_subSections {
|
||||
|
|
|
@ -14,7 +14,7 @@ Please see LICENSE files in the repository root for full details.
|
|||
color: $links;
|
||||
}
|
||||
|
||||
form {
|
||||
form:not(.mx_EncryptionUserSettingsTab form) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-8;
|
||||
|
|
|
@ -15,6 +15,7 @@ import VisibilityOnIcon from "@vector-im/compound-design-tokens/assets/web/icons
|
|||
import NotificationsIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications";
|
||||
import PreferencesIcon from "@vector-im/compound-design-tokens/assets/web/icons/preferences";
|
||||
import KeyboardIcon from "@vector-im/compound-design-tokens/assets/web/icons/keyboard";
|
||||
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key";
|
||||
import SidebarIcon from "@vector-im/compound-design-tokens/assets/web/icons/sidebar";
|
||||
import MicOnIcon from "@vector-im/compound-design-tokens/assets/web/icons/mic-on";
|
||||
import LockIcon from "@vector-im/compound-design-tokens/assets/web/icons/lock";
|
||||
|
@ -44,6 +45,7 @@ import { NonEmptyArray } from "../../../@types/common";
|
|||
import { SDKContext, SdkContextClass } from "../../../contexts/SDKContext";
|
||||
import { useSettingValue } from "../../../hooks/useSettings";
|
||||
import { ToastContext, useActiveToast } from "../../../contexts/ToastContext";
|
||||
import { EncryptionUserSettingsTab } from "../settings/tabs/user/EncryptionUserSettingsTab";
|
||||
|
||||
interface IProps {
|
||||
initialTabId?: UserTab;
|
||||
|
@ -75,6 +77,8 @@ function titleForTabID(tabId: UserTab): React.ReactNode {
|
|||
return _t("settings|voip|dialog_title", undefined, subs);
|
||||
case UserTab.Security:
|
||||
return _t("settings|security|dialog_title", undefined, subs);
|
||||
case UserTab.Encryption:
|
||||
return _t("settings|encryption|dialog_title", undefined, subs);
|
||||
case UserTab.Labs:
|
||||
return _t("settings|labs|dialog_title", undefined, subs);
|
||||
case UserTab.Mjolnir:
|
||||
|
@ -179,6 +183,10 @@ export default function UserSettingsDialog(props: IProps): JSX.Element {
|
|||
),
|
||||
);
|
||||
|
||||
tabs.push(
|
||||
new Tab(UserTab.Encryption, _td("settings|encryption|title"), <KeyIcon />, <EncryptionUserSettingsTab />),
|
||||
);
|
||||
|
||||
if (showLabsFlags() || SettingsStore.getFeatureSettingNames().some((k) => SettingsStore.getBetaInfo(k))) {
|
||||
tabs.push(
|
||||
new Tab(UserTab.Labs, _td("common|labs"), <LabsIcon />, <LabsUserSettingsTab />, "UserSettingsLabs"),
|
||||
|
|
|
@ -15,6 +15,7 @@ export enum UserTab {
|
|||
Sidebar = "USER_SIDEBAR_TAB",
|
||||
Voice = "USER_VOICE_TAB",
|
||||
Security = "USER_SECURITY_TAB",
|
||||
Encryption = "USER_ENCRYPTION_TAB",
|
||||
Labs = "USER_LABS_TAB",
|
||||
Mjolnir = "USER_MJOLNIR_TAB",
|
||||
Help = "USER_HELP_TAB",
|
||||
|
|
33
src/components/views/settings/SettingsHeader.tsx
Normal file
33
src/components/views/settings/SettingsHeader.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* 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, { JSX } from "react";
|
||||
import { Heading } from "@vector-im/compound-web";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
|
||||
/**
|
||||
* The heading for a settings section.
|
||||
*/
|
||||
interface SettingsHeaderProps {
|
||||
/**
|
||||
* Whether the user has a recommended tag.
|
||||
*/
|
||||
hasRecommendedTag?: boolean;
|
||||
/**
|
||||
* The label for the header.
|
||||
*/
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function SettingsHeader({ hasRecommendedTag = false, label }: SettingsHeaderProps): JSX.Element {
|
||||
return (
|
||||
<Heading className="mx_SettingsHeader" as="h2" size="sm" weight="semibold">
|
||||
{label} {hasRecommendedTag && <span>{_t("common|recommended")}</span>}
|
||||
</Heading>
|
||||
);
|
||||
}
|
312
src/components/views/settings/encryption/ChangeRecoveryKey.tsx
Normal file
312
src/components/views/settings/encryption/ChangeRecoveryKey.tsx
Normal file
|
@ -0,0 +1,312 @@
|
|||
/*
|
||||
* 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, { FormEventHandler, JSX, MouseEventHandler, useState } from "react";
|
||||
import {
|
||||
Breadcrumb,
|
||||
IconButton,
|
||||
Button,
|
||||
Root,
|
||||
TextControl,
|
||||
Field,
|
||||
Label,
|
||||
ErrorMessage,
|
||||
Text,
|
||||
} from "@vector-im/compound-web";
|
||||
import CopyIcon from "@vector-im/compound-design-tokens/assets/web/icons/copy";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { _t } from "../../../../languageHandler.tsx";
|
||||
import { EncryptionCard } from "./EncryptionCard.tsx";
|
||||
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext.tsx";
|
||||
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo.ts";
|
||||
import { copyPlaintext } from "../../../../utils/strings.ts";
|
||||
import { withSecretStorageKeyCache } from "../../../../SecurityManager.ts";
|
||||
|
||||
/**
|
||||
* The possible states of the component.
|
||||
* - `warn_user`: The user is warned about the consequences of changing the recovery key.
|
||||
* - `save_key_setup_flow`: The user is asked to save the new recovery key during the setup flow.
|
||||
* - `save_key_change_flow`: The user is asked to save the new recovery key during the chang key flow.
|
||||
* - `confirm`: The user is asked to confirm the new recovery key.
|
||||
*/
|
||||
type State = "warn_user" | "save_key_setup_flow" | "save_key_change_flow" | "confirm";
|
||||
|
||||
interface ChangeRecoveryKeyProps {
|
||||
/**
|
||||
* If true, the component will display the flow to set up a new recovery key.
|
||||
* If false, the component will display the flow to change the recovery key.
|
||||
*/
|
||||
isSetupFlow: boolean;
|
||||
/**
|
||||
* Called when the recovery key is successfully changed.
|
||||
*/
|
||||
onFinish: () => void;
|
||||
/**
|
||||
* Called when the cancel button is clicked or when we go back in the breadcrumbs.
|
||||
*/
|
||||
onCancelClick: () => void;
|
||||
}
|
||||
|
||||
export function ChangeRecoveryKey({
|
||||
isSetupFlow,
|
||||
onFinish,
|
||||
onCancelClick,
|
||||
}: ChangeRecoveryKeyProps): JSX.Element | null {
|
||||
console.log("ChangeRecoveryKey.tsx: ChangeRecoveryKey");
|
||||
const matrixClient = useMatrixClientContext();
|
||||
|
||||
const [state, setState] = useState<State>(isSetupFlow ? "warn_user" : "save_key_change_flow");
|
||||
const labels = getLabels(state);
|
||||
|
||||
const recoveryKey = useAsyncMemo(() => {
|
||||
const crypto = matrixClient.getCrypto();
|
||||
if (!crypto) return Promise.resolve(undefined);
|
||||
|
||||
return crypto.createRecoveryKeyFromPassphrase();
|
||||
}, []);
|
||||
|
||||
console.log(recoveryKey);
|
||||
|
||||
if (!recoveryKey?.encodedPrivateKey) return null;
|
||||
|
||||
console.log("ChangeRecoveryKey.tsx: ChangeRecoveryKey");
|
||||
|
||||
let content: JSX.Element;
|
||||
switch (state) {
|
||||
case "warn_user":
|
||||
content = (
|
||||
<WarningPanel onContinueClick={() => setState("save_key_setup_flow")} onCancelClick={onCancelClick} />
|
||||
);
|
||||
break;
|
||||
case "save_key_setup_flow":
|
||||
content = (
|
||||
<KeyPanel
|
||||
recoveryKey={recoveryKey?.encodedPrivateKey}
|
||||
onConfirmClick={() => setState("confirm")}
|
||||
onCancelClick={onCancelClick}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "save_key_change_flow":
|
||||
content = (
|
||||
<KeyPanel
|
||||
recoveryKey={recoveryKey?.encodedPrivateKey}
|
||||
onConfirmClick={() => setState("confirm")}
|
||||
onCancelClick={onCancelClick}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "confirm":
|
||||
content = (
|
||||
<KeyForm
|
||||
recoveryKey={recoveryKey.encodedPrivateKey}
|
||||
onCancelClick={onCancelClick}
|
||||
onSubmit={async () => {
|
||||
const crypto = matrixClient.getCrypto();
|
||||
if (!crypto) return onFinish();
|
||||
|
||||
try {
|
||||
// We need to enable the cache to avoid to prompt the user to enter the new key
|
||||
// when we will try to access the secret storage during the bootstrap
|
||||
await withSecretStorageKeyCache(() =>
|
||||
crypto.bootstrapSecretStorage({
|
||||
setupNewKeyBackup: isSetupFlow,
|
||||
setupNewSecretStorage: true,
|
||||
createSecretStorageKey: async () => recoveryKey,
|
||||
}),
|
||||
);
|
||||
onFinish();
|
||||
} catch (e) {
|
||||
logger.error("Failed to bootstrap secret storage", e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const pages = [
|
||||
_t("settings|encryption|title"),
|
||||
isSetupFlow
|
||||
? _t("settings|encryption|recovery|set_up_recovery")
|
||||
: _t("settings|encryption|recovery|change_recovery_key"),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb
|
||||
backLabel={_t("action|back")}
|
||||
onBackClick={onCancelClick}
|
||||
pages={pages}
|
||||
onPageClick={onCancelClick}
|
||||
/>
|
||||
<EncryptionCard title={labels.title} description={labels.description} className="mx_ChangeRecoveryKey">
|
||||
{content}
|
||||
</EncryptionCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type Labels = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
function getLabels(state: State): Labels {
|
||||
switch (state) {
|
||||
case "warn_user":
|
||||
return {
|
||||
title: _t("settings|encryption|recovery|set_up_recovery"),
|
||||
description: _t("settings|encryption|recovery|set_up_recovery_description", {
|
||||
changeRecoveryKeyButton: _t("settings|encryption|recovery|change_recovery_key"),
|
||||
}),
|
||||
};
|
||||
case "save_key_setup_flow":
|
||||
return {
|
||||
title: _t("settings|encryption|recovery|set_up_recovery_save_key_title"),
|
||||
description: _t("settings|encryption|recovery|set_up_recovery_save_key_description"),
|
||||
};
|
||||
case "save_key_change_flow":
|
||||
return {
|
||||
title: _t("settings|encryption|recovery|change_recovery_key_title"),
|
||||
description: _t("settings|encryption|recovery|change_recovery_key_description"),
|
||||
};
|
||||
case "confirm":
|
||||
return {
|
||||
title: _t("settings|encryption|recovery|confirm_title"),
|
||||
description: _t("settings|encryption|recovery|confirm_description"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface WarningPanelProps {
|
||||
/**
|
||||
* Called when the continue button is clicked.
|
||||
*/
|
||||
onContinueClick: MouseEventHandler<HTMLButtonElement>;
|
||||
/**
|
||||
* Called when the cancel button is clicked.
|
||||
*/
|
||||
onCancelClick: MouseEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
function WarningPanel({ onContinueClick, onCancelClick }: WarningPanelProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Text as="span" weight="medium" className="mx_WarningPanel_description">
|
||||
{_t("settings|encryption|recovery|set_up_recovery_secondary_description")}
|
||||
</Text>
|
||||
<div className="mx_ChangeRecoveryKey_footer">
|
||||
<Button onClick={onContinueClick}>{_t("action|continue")}</Button>
|
||||
<Button kind="tertiary" onClick={onCancelClick}>
|
||||
{_t("action|cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface KeyPanelProps {
|
||||
/**
|
||||
* Called when the confirm button is clicked.
|
||||
*/
|
||||
onConfirmClick: MouseEventHandler;
|
||||
/**
|
||||
* Called when the cancel button is clicked.
|
||||
*/
|
||||
onCancelClick: MouseEventHandler;
|
||||
/**
|
||||
* The recovery key to display.
|
||||
*/
|
||||
recoveryKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel to display the recovery key.
|
||||
*/
|
||||
function KeyPanel({ recoveryKey, onConfirmClick, onCancelClick }: KeyPanelProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<div className="mx_KeyPanel">
|
||||
<Text as="span" weight="medium">
|
||||
{_t("settings|encryption|recovery|save_key_title")}
|
||||
</Text>
|
||||
<div>
|
||||
<Text as="span" className="mx_KeyPanel_key">
|
||||
{recoveryKey}
|
||||
</Text>
|
||||
<Text as="span" size="sm">
|
||||
{_t("settings|encryption|recovery|save_key_description")}
|
||||
</Text>
|
||||
</div>
|
||||
<IconButton size="28px" onClick={() => copyPlaintext(recoveryKey)}>
|
||||
<CopyIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="mx_ChangeRecoveryKey_footer">
|
||||
<Button onClick={onConfirmClick}>{_t("action|continue")}</Button>
|
||||
<Button kind="tertiary" onClick={onCancelClick}>
|
||||
{_t("action|cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface KeyFormProps {
|
||||
/**
|
||||
* Called when the cancel button is clicked.
|
||||
*/
|
||||
onCancelClick: MouseEventHandler;
|
||||
/**
|
||||
* Called when the form is submitted.
|
||||
*/
|
||||
onSubmit: FormEventHandler;
|
||||
/**
|
||||
* The recovery key to confirm.
|
||||
*/
|
||||
recoveryKey: string;
|
||||
}
|
||||
|
||||
function KeyForm({ onCancelClick, onSubmit, recoveryKey }: KeyFormProps): JSX.Element {
|
||||
// Undefined by default, as the key is not filled yet
|
||||
const [isKeyValid, setIsKeyValid] = useState<boolean>();
|
||||
const isKeyInvalidAndFilled = isKeyValid === false;
|
||||
|
||||
return (
|
||||
<Root
|
||||
className="mx_KeyForm"
|
||||
onSubmit={(evt) => {
|
||||
evt.preventDefault();
|
||||
onSubmit(evt);
|
||||
}}
|
||||
onChange={async (evt) => {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
|
||||
// We don't have any file in the form, we can cast it as string safely
|
||||
const filledKey = new FormData(evt.currentTarget).get("recoveryKey") as string | "";
|
||||
setIsKeyValid(filledKey.trim() === recoveryKey);
|
||||
}}
|
||||
>
|
||||
<Field name="recoveryKey" serverInvalid={isKeyInvalidAndFilled}>
|
||||
<Label>{_t("settings|encryption|recovery|enter_key_title")}</Label>
|
||||
|
||||
<TextControl required={true} />
|
||||
{isKeyInvalidAndFilled && (
|
||||
<ErrorMessage>{_t("settings|encryption|recovery|enter_key_error")}</ErrorMessage>
|
||||
)}
|
||||
</Field>
|
||||
<div className="mx_ChangeRecoveryKey_footer">
|
||||
<Button disabled={!isKeyValid}>{_t("settings|encryption|recovery|confirm_finish")}</Button>
|
||||
<Button kind="tertiary" onClick={onCancelClick}>
|
||||
{_t("action|cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
51
src/components/views/settings/encryption/EncryptionCard.tsx
Normal file
51
src/components/views/settings/encryption/EncryptionCard.tsx
Normal file
|
@ -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, { JSX, PropsWithChildren } from "react";
|
||||
import { BigIcon, Heading } from "@vector-im/compound-web";
|
||||
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key-solid";
|
||||
import classNames from "classnames";
|
||||
|
||||
interface EncryptionCardProps {
|
||||
/**
|
||||
* CSS class name to apply to the card.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* The title of the card.
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* The description of the card.
|
||||
*/
|
||||
description: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A styled card for encryption settings.
|
||||
*/
|
||||
export function EncryptionCard({
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
children,
|
||||
}: PropsWithChildren<EncryptionCardProps>): JSX.Element {
|
||||
return (
|
||||
<div className={classNames("mx_EncryptionCard", className)}>
|
||||
<div className="mx_EncryptionCard_header">
|
||||
<BigIcon>
|
||||
<KeyIcon />
|
||||
</BigIcon>
|
||||
<Heading as="h2" size="sm" weight="semibold">
|
||||
{title}
|
||||
</Heading>
|
||||
<span>{description}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
124
src/components/views/settings/encryption/RecoveryPanel.tsx
Normal file
124
src/components/views/settings/encryption/RecoveryPanel.tsx
Normal file
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* 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, { JSX, MouseEventHandler, useEffect, useState } from "react";
|
||||
import { Button, InlineSpinner } from "@vector-im/compound-web";
|
||||
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key";
|
||||
import CheckCircleIcon from "@vector-im/compound-design-tokens/assets/web/icons/check-circle-solid";
|
||||
|
||||
import { SettingsSection } from "../shared/SettingsSection";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
|
||||
import { SettingsHeader } from "../SettingsHeader";
|
||||
|
||||
type State = "loading" | "missing_backup" | "secrets_not_cached" | "good";
|
||||
|
||||
interface RecoveryPanelProps {
|
||||
/**
|
||||
* Callback for when the user clicks the button to set up their recovery key.
|
||||
*/
|
||||
onSetUpRecoveryClick: MouseEventHandler<HTMLButtonElement>;
|
||||
/**
|
||||
* Callback for when the user clicks the button to change their recovery key.
|
||||
*/
|
||||
onChangingRecoveryKeyClick: MouseEventHandler<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component allows the user to set up or change their recovery key.
|
||||
*/
|
||||
export function RecoveryPanel({ onSetUpRecoveryClick, onChangingRecoveryKeyClick }: RecoveryPanelProps): JSX.Element {
|
||||
const [state, setState] = useState<State>("loading");
|
||||
const isGood = state === "good";
|
||||
const isMissingBackup = state === "missing_backup";
|
||||
const areSecretsNotCached = state === "secrets_not_cached";
|
||||
const hasError = isMissingBackup || areSecretsNotCached;
|
||||
|
||||
const matrixClient = useMatrixClientContext();
|
||||
|
||||
useEffect(() => {
|
||||
const check = async (): Promise<void> => {
|
||||
const crypto = matrixClient.getCrypto();
|
||||
if (!crypto) return;
|
||||
|
||||
console.log("Recovery Panel: Checking recovery key status");
|
||||
|
||||
const hasBackup = (await crypto.getKeyBackupInfo()) && (await crypto.getSessionBackupPrivateKey());
|
||||
if (!hasBackup) return setState("missing_backup");
|
||||
|
||||
const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally;
|
||||
const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey;
|
||||
if (!secretsOk) return setState("secrets_not_cached");
|
||||
|
||||
setState("good");
|
||||
};
|
||||
check();
|
||||
}, [matrixClient]);
|
||||
|
||||
let content: JSX.Element;
|
||||
switch (state) {
|
||||
case "loading":
|
||||
content = <InlineSpinner />;
|
||||
break;
|
||||
case "missing_backup":
|
||||
content = (
|
||||
<Button size="sm" kind="primary" Icon={KeyIcon} onClick={onSetUpRecoveryClick}>
|
||||
{_t("settings|encryption|recovery|set_up_recovery")}
|
||||
</Button>
|
||||
);
|
||||
break;
|
||||
case "secrets_not_cached":
|
||||
content = (
|
||||
<Button size="sm" kind="primary" Icon={KeyIcon}>
|
||||
{_t("settings|encryption|recovery|confirm_recovery_key")}
|
||||
</Button>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
content = (
|
||||
<Button size="sm" kind="secondary" Icon={KeyIcon} onClick={onChangingRecoveryKeyClick}>
|
||||
{_t("settings|encryption|recovery|change_recovery_key")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
legacy={false}
|
||||
heading={<SettingsHeader hasRecommendedTag={hasError} label={_t("settings|encryption|recovery|title")} />}
|
||||
subHeading={<Subheader hasRecoveryKey={isGood} />}
|
||||
className="mx_RecoveryPanel"
|
||||
>
|
||||
{content}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The subheader for the recovery panel.
|
||||
*/
|
||||
interface SubheaderProps {
|
||||
/**
|
||||
* Whether the user has a recovery key.
|
||||
* If null, the recovery key is still fetching.
|
||||
*/
|
||||
hasRecoveryKey: boolean | null;
|
||||
}
|
||||
|
||||
function Subheader({ hasRecoveryKey }: SubheaderProps): JSX.Element {
|
||||
if (!hasRecoveryKey) return <>{_t("settings|encryption|recovery|description")}</>;
|
||||
|
||||
return (
|
||||
<div className="mx_RecoveryPanel_Subheader">
|
||||
{_t("settings|encryption|recovery|description")}
|
||||
<span>
|
||||
<CheckCircleIcon width="20" height="20" />
|
||||
{_t("settings|encryption|recovery|key_active")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -10,19 +10,24 @@ import classnames from "classnames";
|
|||
import React, { HTMLAttributes } from "react";
|
||||
|
||||
import Heading from "../../typography/Heading";
|
||||
import { SettingsHeader } from "../SettingsHeader";
|
||||
|
||||
export interface SettingsSectionProps extends HTMLAttributes<HTMLDivElement> {
|
||||
heading?: string | React.ReactNode;
|
||||
subHeading?: string | React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
legacy?: boolean;
|
||||
}
|
||||
|
||||
function renderHeading(heading: string | React.ReactNode | undefined): React.ReactNode | undefined {
|
||||
function renderHeading(heading: string | React.ReactNode | undefined, legacy: boolean): React.ReactNode | undefined {
|
||||
switch (typeof heading) {
|
||||
case "string":
|
||||
return (
|
||||
return legacy ? (
|
||||
<Heading as="h2" size="3">
|
||||
{heading}
|
||||
</Heading>
|
||||
) : (
|
||||
<SettingsHeader label={heading} />
|
||||
);
|
||||
case "undefined":
|
||||
return undefined;
|
||||
|
@ -31,6 +36,15 @@ function renderHeading(heading: string | React.ReactNode | undefined): React.Rea
|
|||
}
|
||||
}
|
||||
|
||||
function renderSubHeading(subHeading: string | React.ReactNode | undefined): React.ReactNode | undefined {
|
||||
switch (typeof subHeading) {
|
||||
case "undefined":
|
||||
return undefined;
|
||||
default:
|
||||
return subHeading;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A section of settings content
|
||||
* A SettingsTab may contain one or more SettingsSections
|
||||
|
@ -48,9 +62,29 @@ function renderHeading(heading: string | React.ReactNode | undefined): React.Rea
|
|||
* </SettingsTab>
|
||||
* ```
|
||||
*/
|
||||
export const SettingsSection: React.FC<SettingsSectionProps> = ({ className, heading, children, ...rest }) => (
|
||||
<div {...rest} className={classnames("mx_SettingsSection", className)}>
|
||||
{renderHeading(heading)}
|
||||
<div className="mx_SettingsSection_subSections">{children}</div>
|
||||
export const SettingsSection: React.FC<SettingsSectionProps> = ({
|
||||
className,
|
||||
heading,
|
||||
subHeading,
|
||||
legacy = true,
|
||||
children,
|
||||
...rest
|
||||
}) => (
|
||||
<div
|
||||
{...rest}
|
||||
className={classnames("mx_SettingsSection", className, {
|
||||
mx_SettingsSection_newUi: !legacy,
|
||||
})}
|
||||
>
|
||||
{heading &&
|
||||
(subHeading ? (
|
||||
<div className="mx_SettingsSection_header">
|
||||
{renderHeading(heading, legacy)}
|
||||
{renderSubHeading(subHeading)}
|
||||
</div>
|
||||
) : (
|
||||
renderHeading(heading, legacy)
|
||||
))}
|
||||
{legacy ? <div className="mx_SettingsSection_subSections">{children}</div> : children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -6,8 +6,9 @@ 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, { HTMLAttributes } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
export interface SettingsTabProps extends Omit<HTMLAttributes<HTMLDivElement>, "className"> {
|
||||
export interface SettingsTabProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
|
@ -29,8 +30,8 @@ export interface SettingsTabProps extends Omit<HTMLAttributes<HTMLDivElement>, "
|
|||
* </SettingsTab>
|
||||
* ```
|
||||
*/
|
||||
const SettingsTab: React.FC<SettingsTabProps> = ({ children, ...rest }) => (
|
||||
<div {...rest} className="mx_SettingsTab">
|
||||
const SettingsTab: React.FC<SettingsTabProps> = ({ children, className, ...rest }) => (
|
||||
<div {...rest} className={classNames("mx_SettingsTab", className)}>
|
||||
<div className="mx_SettingsTab_sections">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* 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, { JSX, useState } from "react";
|
||||
|
||||
import SettingsTab from "../SettingsTab";
|
||||
import { RecoveryPanel } from "../../encryption/RecoveryPanel";
|
||||
import { ChangeRecoveryKey } from "../../encryption/ChangeRecoveryKey.tsx";
|
||||
|
||||
type Panel = "main" | "change_recovery_key" | "set_recovery_key";
|
||||
|
||||
export function EncryptionUserSettingsTab(): JSX.Element {
|
||||
const [panel, setPanel] = useState<Panel>("main");
|
||||
|
||||
let content: JSX.Element;
|
||||
switch (panel) {
|
||||
case "main":
|
||||
content = (
|
||||
<RecoveryPanel
|
||||
onChangingRecoveryKeyClick={() => setPanel("change_recovery_key")}
|
||||
onSetUpRecoveryClick={() => setPanel("set_recovery_key")}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "set_recovery_key":
|
||||
content = (
|
||||
<ChangeRecoveryKey
|
||||
isSetupFlow={true}
|
||||
onCancelClick={() => setPanel("main")}
|
||||
onFinish={() => setPanel("main")}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "change_recovery_key":
|
||||
content = (
|
||||
<ChangeRecoveryKey
|
||||
isSetupFlow={false}
|
||||
onCancelClick={() => setPanel("main")}
|
||||
onFinish={() => setPanel("main")}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return <SettingsTab className="mx_EncryptionUserSettingsTab">{content}</SettingsTab>;
|
||||
}
|
|
@ -542,6 +542,7 @@
|
|||
"qr_code": "QR Code",
|
||||
"random": "Random",
|
||||
"reactions": "Reactions",
|
||||
"recommended": "Recommended",
|
||||
"report_a_bug": "Report a bug",
|
||||
"room": "Room",
|
||||
"room_name": "Room name",
|
||||
|
@ -2461,6 +2462,31 @@
|
|||
"emoji_autocomplete": "Enable Emoji suggestions while typing",
|
||||
"enable_markdown": "Enable Markdown",
|
||||
"enable_markdown_description": "Start messages with <code>/plain</code> to send without markdown.",
|
||||
"encryption": {
|
||||
"dialog_title": "<strong>Settings:</strong> Encryption",
|
||||
"recovery": {
|
||||
"change_recovery_key": "Change recovery key",
|
||||
"change_recovery_key_description": "Get a new recovery key if you've lost your existing one. After changing your recovery key, your old one will no longer work.",
|
||||
"change_recovery_key_title": "Change recovery key?",
|
||||
"confirm_description": "Enter the recovery key shown on the previous screen to finish setting up recovery.",
|
||||
"confirm_finish": "Finish set up",
|
||||
"confirm_recovery_key": "Confirm recovery key",
|
||||
"confirm_title": "Enter your recovery key to confirm",
|
||||
"description": "Recover your cryptographic identity and message history with a recovery key if you’ve lost all your existing devices.",
|
||||
"enter_key_error": "The recovery key you entered is not correct.",
|
||||
"enter_key_title": "Enter recovery key",
|
||||
"key_active": "Recovery key active",
|
||||
"save_key_description": "Do not share this with anyone!",
|
||||
"save_key_title": "Recovery key",
|
||||
"set_up_recovery": "Set up recovery",
|
||||
"set_up_recovery_description": "Your key storage is protected by a recovery key. If you need a new recovery key after setup, you can recreate it by selecting ‘%(changeRecoveryKeyButton)s’.",
|
||||
"set_up_recovery_save_key_description": "Write down this recovery key somewhere safe, like a password manager, encrypted note, or a physical safe.",
|
||||
"set_up_recovery_save_key_title": "Save your recovery key somewhere safe",
|
||||
"set_up_recovery_secondary_description": "After clicking continue, we’ll generate a recovery key for you.",
|
||||
"title": "Recovery"
|
||||
},
|
||||
"title": "Encryption"
|
||||
},
|
||||
"general": {
|
||||
"account_management_section": "Account management",
|
||||
"account_section": "Account",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue