Add recovery section

This commit is contained in:
Florian Duros 2024-12-06 11:51:34 +01:00
parent 8747e62103
commit 1811aa6c98
No known key found for this signature in database
GPG key ID: A5BBB4041B493F15
11 changed files with 693 additions and 10 deletions

View file

@ -596,7 +596,9 @@ legend {
.mx_Dialog .mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button .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 input[type="submit"],
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton), .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton),
.mx_Dialog_buttons input[type="submit"] { .mx_Dialog_buttons input[type="submit"] {
@ -616,8 +618,8 @@ legend {
.mx_Dialog .mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button .mx_UserProfileSettings button
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not( ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):not(.mx_ShareDialog button):not(
.mx_ShareDialog button .mx_EncryptionUserSettingsTab button
):last-child { ):last-child {
margin-right: 0px; margin-right: 0px;
} }
@ -625,7 +627,9 @@ legend {
.mx_Dialog .mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button .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 input[type="submit"]:focus,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus,
.mx_Dialog_buttons input[type="submit"]:focus { .mx_Dialog_buttons input[type="submit"]:focus {
@ -637,7 +641,9 @@ legend {
.mx_Dialog_buttons .mx_Dialog_buttons
button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not( button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button .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 { .mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
color: var(--cpd-color-text-on-solid-primary); color: var(--cpd-color-text-on-solid-primary);
background-color: var(--cpd-color-bg-action-primary-rest); background-color: var(--cpd-color-bg-action-primary-rest);
@ -650,7 +656,7 @@ legend {
.mx_Dialog_buttons .mx_Dialog_buttons
button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not( button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not(
.mx_ThemeChoicePanel_CustomTheme button .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 { .mx_Dialog_buttons input[type="submit"].danger {
background-color: var(--cpd-color-bg-critical-primary); background-color: var(--cpd-color-bg-critical-primary);
border: solid 1px var(--cpd-color-bg-critical-primary); border: solid 1px var(--cpd-color-bg-critical-primary);
@ -666,7 +672,9 @@ legend {
.mx_Dialog .mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button .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 input[type="submit"]:disabled,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled,
.mx_Dialog_buttons input[type="submit"]:disabled { .mx_Dialog_buttons input[type="submit"]:disabled {

View file

@ -351,6 +351,9 @@
@import "./views/settings/_ThemeChoicePanel.pcss"; @import "./views/settings/_ThemeChoicePanel.pcss";
@import "./views/settings/_UpdateCheckButton.pcss"; @import "./views/settings/_UpdateCheckButton.pcss";
@import "./views/settings/_UserProfileSettings.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/_SettingsBanner.pcss";
@import "./views/settings/tabs/_SettingsIndent.pcss"; @import "./views/settings/tabs/_SettingsIndent.pcss";
@import "./views/settings/tabs/_SettingsSection.pcss"; @import "./views/settings/tabs/_SettingsSection.pcss";

View 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;
}
}

View 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;
}
}
}

View 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);
}
}
}

View file

@ -14,7 +14,7 @@ Please see LICENSE files in the repository root for full details.
color: $links; color: $links;
} }
form { form:not(.mx_EncryptionUserSettingsTab form) {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $spacing-8; gap: $spacing-8;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

@ -5,10 +5,46 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
import React, { JSX } from "react"; import React, { JSX, useState } from "react";
import SettingsTab from "../SettingsTab"; 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 { export function EncryptionUserSettingsTab(): JSX.Element {
return <SettingsTab />; 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>;
} }

View file

@ -2464,6 +2464,27 @@
"enable_markdown_description": "Start messages with <code>/plain</code> to send without markdown.", "enable_markdown_description": "Start messages with <code>/plain</code> to send without markdown.",
"encryption": { "encryption": {
"dialog_title": "<strong>Settings:</strong> 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 youve 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, well generate a recovery key for you.",
"title": "Recovery"
},
"title": "Encryption" "title": "Encryption"
}, },
"general": { "general": {