element-portable/src/components/views/settings/encryption/ChangeRecoveryKey.tsx
2024-12-11 18:22:25 +01:00

312 lines
11 KiB
TypeScript

/*
* 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>
);
}