Files
element-web/src/components/views/settings/encryption/ChangeRecoveryKey.tsx
David Baker b7fea97bb6 Factor out duplicated CSS for buttons in encryption settings (#29269)
* Factor out duplicated CSS for buttons in encryption settings

By adding a component to hold the common CSS

* Update snapshot

* Update snapshot

* More snapshots

* Split EncryptionCardButtons out to separate component

* Update imports
2025-02-18 11:01:48 +00:00

362 lines
13 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, { type FormEventHandler, type JSX, type MouseEventHandler, useState } from "react";
import {
Breadcrumb,
Button,
ErrorMessage,
Field,
IconButton,
Label,
Root,
Text,
TextControl,
} from "@vector-im/compound-web";
import CopyIcon from "@vector-im/compound-design-tokens/assets/web/icons/copy";
import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key-solid";
import { _t } from "../../../../languageHandler";
import { EncryptionCard } from "./EncryptionCard";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
import { copyPlaintext } from "../../../../utils/strings";
import { initialiseDehydrationIfEnabled } from "../../../../utils/device/dehydration.ts";
import { withSecretStorageKeyCache } from "../../../../SecurityManager";
import { EncryptionCardButtons } from "./EncryptionCardButtons";
import { logErrorAndShowErrorDialog } from "../../../../utils/ErrorUtils.tsx";
/**
* The possible states of the component.
* - `inform_user`: The user is informed about 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 change key flow.
* - `confirm_key_setup_flow`: The user is asked to confirm the new recovery key during the set up flow.
* - `confirm_key_change_flow`: The user is asked to confirm the new recovery key during the change key flow.
*/
type State =
| "inform_user"
| "save_key_setup_flow"
| "save_key_change_flow"
| "confirm_key_setup_flow"
| "confirm_key_change_flow";
interface ChangeRecoveryKeyProps {
/**
* If true, the component will display the flow to change the recovery key.
* If false,the component will display the flow to set up a new recovery key.
*/
userHasRecoveryKey: 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;
}
/**
* A component to set up or change the recovery key.
*/
export function ChangeRecoveryKey({
userHasRecoveryKey,
onFinish,
onCancelClick,
}: ChangeRecoveryKeyProps): JSX.Element | null {
const matrixClient = useMatrixClientContext();
// If the user is setting up recovery for the first time, we first show them a panel explaining what
// "recovery" is about. Otherwise, we jump straight to showing the user the new key.
const [state, setState] = useState<State>(userHasRecoveryKey ? "save_key_change_flow" : "inform_user");
// We create a new recovery key, the recovery key will be displayed to the user
const recoveryKey = useAsyncMemo(() => matrixClient.getCrypto()!.createRecoveryKeyFromPassphrase(), []);
// Waiting for the recovery key to be generated
if (!recoveryKey) return null;
let content: JSX.Element;
switch (state) {
case "inform_user":
// Show a panel explaining what "recovery" is for, and what a recovery key does.
content = (
<InformationPanel
onContinueClick={() => setState("save_key_setup_flow")}
onCancelClick={onCancelClick}
/>
);
break;
case "save_key_setup_flow":
case "save_key_change_flow":
// Show a generated recovery key and ask the user to save it.
content = (
<KeyPanel
// encodedPrivateKey is always defined, the optional typing is incorrect
recoveryKey={recoveryKey.encodedPrivateKey!}
onConfirmClick={() =>
setState((currentState) =>
currentState === "save_key_change_flow"
? "confirm_key_change_flow"
: "confirm_key_setup_flow",
)
}
onCancelClick={onCancelClick}
/>
);
break;
case "confirm_key_setup_flow":
case "confirm_key_change_flow":
// Ask the user to enter the recovery key they just saved to confirm it.
content = (
<KeyForm
// encodedPrivateKey is always defined, the optional typing is incorrect
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(async () => {
await crypto.bootstrapSecretStorage({
setupNewSecretStorage: true,
createSecretStorageKey: async () => recoveryKey,
});
await initialiseDehydrationIfEnabled(matrixClient, { createNewKey: true });
});
onFinish();
} catch (e) {
logErrorAndShowErrorDialog("Failed to set up secret storage", e);
}
}}
submitButtonLabel={
state === "confirm_key_setup_flow"
? _t("settings|encryption|recovery|set_up_recovery_confirm_button")
: _t("settings|encryption|recovery|change_recovery_confirm_button")
}
/>
);
}
const pages = [
_t("settings|encryption|title"),
userHasRecoveryKey
? _t("settings|encryption|recovery|change_recovery_key")
: _t("settings|encryption|recovery|set_up_recovery"),
];
const labels = getLabels(state);
return (
<>
<Breadcrumb
backLabel={_t("action|back")}
onBackClick={onCancelClick}
pages={pages}
onPageClick={onCancelClick}
/>
<EncryptionCard
Icon={KeyIcon}
title={labels.title}
description={labels.description}
className="mx_ChangeRecoveryKey"
>
{content}
</EncryptionCard>
</>
);
}
type Labels = {
/**
* The title of the card.
*/
title: string;
/**
* The description of the card.
*/
description: string;
};
/**
* Get the header title and description for the given state.
* @param state
*/
function getLabels(state: State): Labels {
switch (state) {
case "inform_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_key_setup_flow":
return {
title: _t("settings|encryption|recovery|set_up_recovery_confirm_title"),
description: _t("settings|encryption|recovery|set_up_recovery_confirm_description"),
};
case "confirm_key_change_flow":
return {
title: _t("settings|encryption|recovery|change_recovery_confirm_title"),
description: _t("settings|encryption|recovery|change_recovery_confirm_description"),
};
}
}
interface InformationPanelProps {
/**
* Called when the continue button is clicked.
*/
onContinueClick: MouseEventHandler<HTMLButtonElement>;
/**
* Called when the cancel button is clicked.
*/
onCancelClick: MouseEventHandler<HTMLButtonElement>;
}
/**
* The panel to display information about the recovery key.
*/
function InformationPanel({ onContinueClick, onCancelClick }: InformationPanelProps): JSX.Element {
return (
<>
<Text as="span" weight="medium" className="mx_InformationPanel_description">
{_t("settings|encryption|recovery|set_up_recovery_secondary_description")}
</Text>
<EncryptionCardButtons>
<Button onClick={onContinueClick}>{_t("action|continue")}</Button>
<Button kind="tertiary" onClick={onCancelClick}>
{_t("action|cancel")}
</Button>
</EncryptionCardButtons>
</>
);
}
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" data-testid="recoveryKey">
{recoveryKey}
</Text>
<Text as="span" size="sm">
{_t("settings|encryption|recovery|save_key_description")}
</Text>
</div>
<IconButton aria-label={_t("action|copy")} size="28px" onClick={() => copyPlaintext(recoveryKey)}>
<CopyIcon />
</IconButton>
</div>
<EncryptionCardButtons>
<Button onClick={onConfirmClick}>{_t("action|continue")}</Button>
<Button kind="tertiary" onClick={onCancelClick}>
{_t("action|cancel")}
</Button>
</EncryptionCardButtons>
</>
);
}
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;
/**
* The label for the submit button.
*/
submitButtonLabel: string;
}
/**
* The form to confirm the recovery key.
* The finish button is disabled until the key is filled and valid.
* The entered key is valid if it matches the recovery key.
*/
function KeyForm({ onCancelClick, onSubmit, recoveryKey, submitButtonLabel }: 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_recovery_key")}</Label>
<TextControl required={true} />
{isKeyInvalidAndFilled && (
<ErrorMessage>{_t("settings|encryption|recovery|enter_key_error")}</ErrorMessage>
)}
</Field>
<EncryptionCardButtons>
<Button disabled={!isKeyValid}>{submitButtonLabel}</Button>
<Button kind="tertiary" onClick={onCancelClick}>
{_t("action|cancel")}
</Button>
</EncryptionCardButtons>
</Root>
);
}