Files
Desktop/ts/components/PreferencesLocalBackups.dom.tsx
2026-03-10 14:51:23 -04:00

726 lines
23 KiB
TypeScript

// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ChangeEvent } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useState,
useRef,
} from 'react';
import lodash from 'lodash';
import classNames from 'classnames';
import type { LocalizerType } from '../types/I18N.std.js';
import {
FlowingSettingsControl as FlowingControl,
SettingsRow,
} from './PreferencesUtil.dom.js';
import { SIGNAL_BACKUPS_LEARN_MORE_URL } from './PreferencesBackups.dom.js';
import { I18n } from './I18n.dom.js';
import type { SettingsLocation } from '../types/Nav.std.js';
import { SettingsPage } from '../types/Nav.std.js';
import { ToastType } from '../types/Toast.dom.js';
import type { ShowToastAction } from '../state/ducks/toast.preload.js';
import { Modal } from './Modal.dom.js';
import { strictAssert } from '../util/assert.std.js';
import type {
PromptOSAuthReasonType,
PromptOSAuthResultType,
} from '../util/os/promptOSAuthMain.main.js';
import { AxoButton } from '../axo/AxoButton.dom.js';
import { AxoDialog } from '../axo/AxoDialog.dom.js';
import { AxoCheckbox } from '../axo/AxoCheckbox.dom.js';
import { SECOND } from '../util/durations/constants.std.js';
import { formatTimestamp } from '../util/formatTimestamp.dom.js';
import type { LocalBackupExportMetadata } from '../types/LocalExport.std.js';
import { tw } from '../axo/tw.dom.js';
import { createLogger } from '../logging/log.std.js';
import { toLogFormat } from '../types/errors.std.js';
import { AxoAlertDialog } from '../axo/AxoAlertDialog.dom.js';
const { noop } = lodash;
const log = createLogger('PreferencesLocalBackups');
export function PreferencesLocalBackups({
accountEntropyPool,
backupKeyViewed,
disableLocalBackups,
i18n,
lastLocalBackup,
localBackupFolder,
openFileInFolder,
osName,
onBackupKeyViewedChange,
settingsLocation,
pickLocalBackupFolder,
promptOSAuth,
setSettingsLocation,
showToast,
startLocalBackupExport,
}: {
accountEntropyPool: string | undefined;
backupKeyViewed: boolean;
disableLocalBackups: ({
deleteExistingBackups,
}: {
deleteExistingBackups: boolean;
}) => Promise<void>;
i18n: LocalizerType;
lastLocalBackup: LocalBackupExportMetadata | undefined;
localBackupFolder: string | undefined;
onBackupKeyViewedChange: (keyViewed: boolean) => void;
openFileInFolder: (path: string) => void;
osName: 'linux' | 'macos' | 'windows' | undefined;
settingsLocation: SettingsLocation;
pickLocalBackupFolder: () => Promise<string | undefined>;
promptOSAuth: (
reason: PromptOSAuthReasonType
) => Promise<PromptOSAuthResultType>;
setSettingsLocation: (settingsLocation: SettingsLocation) => void;
showToast: ShowToastAction;
startLocalBackupExport: () => void;
}): React.JSX.Element {
const [authError, setAuthError] =
React.useState<Omit<PromptOSAuthResultType, 'success'>>();
const [isAuthPending, setIsAuthPending] = useState<boolean>(false);
const [isDisablePending, setIsDisablePending] = useState<boolean>(false);
if (!localBackupFolder) {
return (
<LocalBackupsSetupFolderPicker
i18n={i18n}
pickLocalBackupFolder={pickLocalBackupFolder}
/>
);
}
const isReferencingBackupKey =
settingsLocation.page === SettingsPage.LocalBackupsKeyReference;
if (!backupKeyViewed || isReferencingBackupKey) {
strictAssert(accountEntropyPool, 'AEP is required for backup key viewer');
return (
<LocalBackupsBackupKeyViewer
accountEntropyPool={accountEntropyPool}
i18n={i18n}
isReferencing={isReferencingBackupKey}
onBackupKeyViewed={() => {
if (backupKeyViewed) {
setSettingsLocation({
page: SettingsPage.LocalBackups,
});
} else {
onBackupKeyViewedChange(true);
}
}}
showToast={showToast}
/>
);
}
const learnMoreLink = (parts: Array<string | React.JSX.Element>) => (
<a href={SIGNAL_BACKUPS_LEARN_MORE_URL} rel="noreferrer" target="_blank">
{parts}
</a>
);
const lastBackupText = lastLocalBackup
? formatTimestamp(lastLocalBackup.timestamp, {
dateStyle: 'medium',
timeStyle: 'short',
})
: i18n('icu:Preferences__local-backups-last-backup-never');
let showInFolderText = i18n(
'icu:PlaintextExport--CompleteDialog--ShowFiles--Windows'
);
if (osName === 'macos') {
showInFolderText = i18n(
'icu:PlaintextExport--CompleteDialog--ShowFiles--Mac'
);
} else if (osName === 'linux') {
showInFolderText = i18n(
'icu:PlaintextExport--CompleteDialog--ShowFiles--Linux'
);
}
return (
<>
<div className="Preferences__padding">
<div className="Preferences__description Preferences__description--medium">
{i18n('icu:Preferences__local-backups-section__description')}
</div>
</div>
<SettingsRow className="Preferences--BackupsRow">
<FlowingControl>
<div className="Preferences__two-thirds-flow">
<label>
{i18n('icu:Preferences__local-backups-last-backup')}
<div className="Preferences__description">{lastBackupText}</div>
</label>
</div>
<div
className={classNames(
'Preferences__flow-button',
'Preferences__one-third-flow',
'Preferences__one-third-flow--align-right'
)}
>
<AxoButton.Root
variant="secondary"
size="lg"
onClick={startLocalBackupExport}
>
{i18n('icu:Preferences__local-backups-backup-now')}
</AxoButton.Root>
</div>
</FlowingControl>
<FlowingControl>
<div className="Preferences__two-thirds-flow">
<label>
{i18n('icu:Preferences__local-backups-folder')}
<div className="Preferences__description">
{localBackupFolder}
</div>
</label>
</div>
<div
className={classNames(
'Preferences__flow-button',
'Preferences__one-third-flow',
'Preferences__one-third-flow--align-right'
)}
>
<AxoButton.Root
variant="secondary"
size="lg"
onClick={() => openFileInFolder(localBackupFolder)}
>
{showInFolderText}
</AxoButton.Root>
</div>
</FlowingControl>
<FlowingControl>
<div className="Preferences__two-thirds-flow">
<label>
{i18n('icu:Preferences__backup-key')}
<div className="Preferences__description">
{i18n('icu:Preferences__backup-key-description')}
</div>
</label>
</div>
<div
className={classNames(
'Preferences__flow-button',
'Preferences__one-third-flow',
'Preferences__one-third-flow--align-right'
)}
>
<AxoButton.Root
variant="secondary"
size="lg"
disabled={isAuthPending}
experimentalSpinner={
isAuthPending ? { 'aria-label': i18n('icu:loading') } : null
}
onClick={async () => {
setAuthError(undefined);
try {
setIsAuthPending(true);
const result = await promptOSAuth('view-aep');
if (result === 'success' || result === 'unsupported') {
setSettingsLocation({
page: SettingsPage.LocalBackupsKeyReference,
});
} else {
setAuthError(result);
}
} finally {
setIsAuthPending(false);
}
}}
>
{i18n('icu:Preferences__view-key')}
</AxoButton.Root>
</div>
</FlowingControl>
<FlowingControl>
<div className="Preferences__two-thirds-flow">
<label>{i18n('icu:Preferences__local-backups-turn-off')}</label>
</div>
<div
className={classNames(
'Preferences__flow-button',
'Preferences__one-third-flow',
'Preferences__one-third-flow--align-right'
)}
>
<AxoButton.Root
variant="subtle-destructive"
size="lg"
onClick={() => {
setIsDisablePending(true);
}}
>
{i18n('icu:Preferences__local-backups-turn-off-action')}
</AxoButton.Root>
</div>
</FlowingControl>
</SettingsRow>
<SettingsRow className="Preferences--BackupsRow">
<div className="Preferences__padding">
<div className="Preferences__description Preferences__description--medium">
<I18n
id="icu:Preferences--local-backups-restore-info"
i18n={i18n}
components={{
learnMoreLink,
}}
/>
</div>
</div>
</SettingsRow>
{isDisablePending ? (
<DisableLocalBackupsDialog
i18n={i18n}
disableLocalBackups={disableLocalBackups}
onCancel={() => setIsDisablePending(false)}
onComplete={() => {
setIsDisablePending(false);
setSettingsLocation({ page: SettingsPage.Backups });
}}
onError={() => {
showToast({
toastType: ToastType.Error,
});
setIsDisablePending(false);
setSettingsLocation({ page: SettingsPage.Backups });
}}
/>
) : null}
{authError ? (
<AxoAlertDialog.Root
open
onOpenChange={open => {
if (!open) {
setAuthError(undefined);
}
}}
>
<AxoAlertDialog.Content escape="cancel-is-noop">
<AxoAlertDialog.Body>
<AxoAlertDialog.Description>
{getOSAuthErrorString(authError) ?? i18n('icu:error')}
</AxoAlertDialog.Description>
</AxoAlertDialog.Body>
<AxoAlertDialog.Footer>
<AxoAlertDialog.Cancel>{i18n('icu:ok')}</AxoAlertDialog.Cancel>
</AxoAlertDialog.Footer>
</AxoAlertDialog.Content>
</AxoAlertDialog.Root>
) : null}
</>
);
}
function DisableLocalBackupsDialog({
i18n,
disableLocalBackups,
onComplete,
onCancel,
onError,
}: {
i18n: LocalizerType;
disableLocalBackups: ({
deleteExistingBackups,
}: {
deleteExistingBackups: boolean;
}) => Promise<void>;
onComplete: () => void;
onCancel: () => void;
onError: (e: unknown) => void;
}) {
const [isPending, setIsPending] = useState<boolean>(false);
const [deleteExistingBackups, setDeleteExistingBackups] =
useState<boolean>(true);
const handleDisableLocalBackups = useCallback(async () => {
if (isPending) {
return;
}
try {
setIsPending(true);
await disableLocalBackups({ deleteExistingBackups });
onComplete();
} catch (e) {
log.error(
'Error when disabling local backups',
{ deleteExistingBackups },
toLogFormat(e)
);
onError(e);
} finally {
setIsPending(false);
}
}, [
isPending,
deleteExistingBackups,
onComplete,
onError,
disableLocalBackups,
]);
return (
<AxoDialog.Root
open
onOpenChange={(open: boolean) => {
if (isPending) {
return;
}
if (!open) {
onCancel();
}
}}
>
<AxoDialog.Content
size="md"
escape={isPending ? 'cancel-is-destructive' : 'cancel-is-noop'}
>
<div className={tw('p-2')}>
<AxoDialog.Header>
<AxoDialog.Title>
{i18n('icu:Preferences__local-backups-turn-off')}
</AxoDialog.Title>
</AxoDialog.Header>
<AxoDialog.Body padding="normal">
<AxoDialog.Description>
<div className={tw('mb-2 text-label-secondary')}>
{i18n('icu:Preferences__local-backups-turn-off-confirmation')}
</div>
</AxoDialog.Description>
<label
className={tw('flex items-center gap-3 px-4 py-2.5')}
htmlFor="deleteLocalBackupsCheckbox"
>
<AxoCheckbox.Root
id="deleteLocalBackupsCheckbox"
variant="square"
checked={deleteExistingBackups}
disabled={isPending}
onCheckedChange={setDeleteExistingBackups}
/>
{i18n('icu:Preferences__local-backups-turn-off-delete')}
</label>
</AxoDialog.Body>
<AxoDialog.Footer>
<AxoDialog.Actions>
<AxoDialog.Action
variant="secondary"
onClick={onCancel}
disabled={isPending}
>
{i18n('icu:cancel')}
</AxoDialog.Action>
<AxoDialog.Action
variant="destructive"
experimentalSpinner={
isPending ? { 'aria-label': i18n('icu:loading') } : null
}
onClick={handleDisableLocalBackups}
>
{i18n('icu:Preferences__local-backups-turn-off-action')}
</AxoDialog.Action>
</AxoDialog.Actions>
</AxoDialog.Footer>
</div>
</AxoDialog.Content>
</AxoDialog.Root>
);
}
function LocalBackupsSetupFolderPicker({
i18n,
pickLocalBackupFolder,
}: {
i18n: LocalizerType;
pickLocalBackupFolder: () => Promise<string | undefined>;
}): React.JSX.Element {
return (
<div className="Preferences--LocalBackupsSetupScreen Preferences__padding">
<div className="Preferences--LocalBackupsSetupScreenPaneContent">
<div className="Preferences--LocalBackupsSetupIcon Preferences--LocalBackupsSetupIcon-folder" />
<legend className="Preferences--LocalBackupsSetupScreenHeader">
{i18n('icu:Preferences--local-backups-setup-folder')}
</legend>
<div className="Preferences--LocalBackupsSetupScreenBody Preferences--LocalBackupsSetupScreenBody--folder">
{i18n('icu:Preferences--local-backups-setup-folder-description')}
</div>
<AxoButton.Root
variant="primary"
size="lg"
onClick={pickLocalBackupFolder}
>
{i18n('icu:Preferences__button--choose-folder')}
</AxoButton.Root>
</div>
</div>
);
}
type BackupKeyStep = 'view' | 'confirm' | 'caution' | 'reference';
function LocalBackupsBackupKeyViewer({
accountEntropyPool,
i18n,
isReferencing,
onBackupKeyViewed,
showToast,
}: {
accountEntropyPool: string;
i18n: LocalizerType;
isReferencing: boolean;
onBackupKeyViewed: () => void;
showToast: ShowToastAction;
}): React.JSX.Element {
const [isBackupKeyConfirmed, setIsBackupKeyConfirmed] =
useState<boolean>(false);
const [step, setStep] = useState<BackupKeyStep>(
isReferencing ? 'reference' : 'view'
);
const isStepViewOrReference = step === 'view' || step === 'reference';
const backupKey = useMemo(() => {
return accountEntropyPool
.replace(/\s/g, '')
.replace(/.{4}(?=.)/g, '$& ')
.toUpperCase();
}, [accountEntropyPool]);
const onCopyBackupKey = useCallback(
async function handleCopyBackupKey(e: React.MouseEvent) {
e.preventDefault();
await window.SignalClipboard.copyTextTemporarily(backupKey, 45 * SECOND);
showToast({ toastType: ToastType.CopiedBackupKey });
},
[backupKey, showToast]
);
const learnMoreLink = (parts: Array<string | React.JSX.Element>) => (
<a href={SIGNAL_BACKUPS_LEARN_MORE_URL} rel="noreferrer" target="_blank">
{parts}
</a>
);
let title: string;
let description: React.JSX.Element | string;
let footerLeft: React.JSX.Element | undefined;
let footerRight: React.JSX.Element;
if (isStepViewOrReference) {
title = i18n('icu:Preferences--local-backups-record-backup-key');
description = (
<I18n
id="icu:Preferences--local-backups-record-backup-key-description"
i18n={i18n}
components={{
learnMoreLink,
}}
/>
);
if (step === 'view') {
footerRight = (
<AxoButton.Root
variant="primary"
size="lg"
onClick={() => setStep('confirm')}
>
{i18n('icu:Preferences--local-backups-setup-next')}
</AxoButton.Root>
);
} else {
footerRight = (
<AxoButton.Root variant="primary" size="lg" onClick={onBackupKeyViewed}>
{i18n('icu:Preferences--local-backups-view-backup-key-done')}
</AxoButton.Root>
);
}
} else {
title = i18n('icu:Preferences--local-backups-confirm-backup-key');
description = i18n(
'icu:Preferences--local-backups-confirm-backup-key-description'
);
footerLeft = (
<AxoButton.Root
variant="borderless-primary"
size="lg"
onClick={() => setStep('view')}
>
{i18n('icu:Preferences--local-backups-see-backup-key-again')}
</AxoButton.Root>
);
footerRight = (
<AxoButton.Root
variant="primary"
size="lg"
disabled={!isBackupKeyConfirmed}
onClick={() => setStep('caution')}
>
{i18n('icu:Preferences--local-backups-setup-next')}
</AxoButton.Root>
);
}
return (
<div className="Preferences--LocalBackupsSetupScreen Preferences__settings-pane-content--with-footer Preferences__padding">
{step === 'caution' && (
<Modal
i18n={i18n}
modalName="CallingAdhocCallInfo.UnknownContactInfo"
moduleClassName="Preferences--LocalBackupsConfirmKeyModal"
modalFooter={
<AxoButton.Root
variant="primary"
size="lg"
onClick={onBackupKeyViewed}
>
{i18n(
'icu:Preferences__local-backups-confirm-key-modal-continue'
)}
</AxoButton.Root>
}
onClose={() => setStep('confirm')}
padded={false}
>
<div className="Preferences--LocalBackupsSetupIcon Preferences--LocalBackupsSetupIcon-key" />
<legend className="Preferences--LocalBackupsConfirmKeyModalTitle">
{i18n('icu:Preferences__local-backups-confirm-key-modal-title')}
</legend>
<div className="Preferences--LocalBackupsConfirmKeyModalBody">
{i18n('icu:Preferences__local-backups-confirm-key-modal-body')}
</div>
</Modal>
)}
<div className="Preferences--LocalBackupsSetupScreenPane Preferences--LocalBackupsSetupScreenPane-top">
<div className="Preferences--LocalBackupsSetupScreenPaneContent">
<div className="Preferences--LocalBackupsSetupIcon Preferences--LocalBackupsSetupIcon-lock" />
<legend className="Preferences--LocalBackupsSetupScreenHeader">
{title}
</legend>
<div className="Preferences--LocalBackupsSetupScreenBody">
{description}
</div>
</div>
</div>
<div className="Preferences--LocalBackupsSetupScreenPane">
<div className="Preferences--LocalBackupsSetupScreenPaneContent">
<LocalBackupsBackupKeyTextarea
backupKey={backupKey}
i18n={i18n}
onValidate={(isValid: boolean) => setIsBackupKeyConfirmed(isValid)}
isStepViewOrReference={isStepViewOrReference}
/>
</div>
{isStepViewOrReference && (
<div className="Preferences--LocalBackupsSetupScreenPaneContent">
<AxoButton.Root
variant="secondary"
size="sm"
symbol="copy"
onClick={onCopyBackupKey}
>
{i18n('icu:Preferences__local-backups-copy-key')}
</AxoButton.Root>
</div>
)}
</div>
<div className="Preferences--LocalBackupsSetupScreenPane Preferences--LocalBackupsSetupScreenPane-footer">
<div className="Preferences--LocalBackupsSetupScreenFooterSection">
{footerLeft}
</div>
<div className="Preferences--LocalBackupsSetupScreenFooterSection Preferences--LocalBackupsSetupScreenFooterSection-right">
{footerRight}
</div>
</div>
</div>
);
}
function LocalBackupsBackupKeyTextarea({
backupKey,
i18n,
onValidate,
isStepViewOrReference,
}: {
backupKey: string;
i18n: LocalizerType;
onValidate: (isValid: boolean) => void;
isStepViewOrReference: boolean;
}): React.JSX.Element {
const backupKeyTextareaRef = useRef<HTMLTextAreaElement>(null);
const [backupKeyInput, setBackupKeyInput] = useState<string>('');
useEffect(() => {
if (backupKeyTextareaRef.current) {
backupKeyTextareaRef.current.focus();
}
}, [backupKeyTextareaRef, isStepViewOrReference]);
const backupKeyNoSpaces = React.useMemo(() => {
return backupKey.replace(/\s/g, '');
}, [backupKey]);
const handleTextareaChange = useCallback(
(ev: ChangeEvent<HTMLTextAreaElement>) => {
const { value } = ev.target;
const valueUppercaseNoSpaces = value.replace(/\s/g, '').toUpperCase();
const valueForUI = valueUppercaseNoSpaces.replace(/.{4}(?=.)/g, '$& ');
setBackupKeyInput(valueForUI);
onValidate(valueUppercaseNoSpaces === backupKeyNoSpaces);
},
[backupKeyNoSpaces, onValidate]
);
return (
<textarea
aria-label={i18n('icu:Preferences--local-backups-backup-key-text-box')}
className="Preferences--LocalBackupsBackupKey"
cols={20}
dir="ltr"
rows={4}
maxLength={79}
onChange={isStepViewOrReference ? noop : handleTextareaChange}
placeholder={i18n('icu:Preferences--local-backups-enter-backup-key')}
readOnly={isStepViewOrReference}
ref={backupKeyTextareaRef}
spellCheck="false"
value={isStepViewOrReference ? backupKey : backupKeyInput}
/>
);
}
function getOSAuthErrorString(
authError: Omit<PromptOSAuthResultType, 'success'> | undefined
): string | undefined {
if (!authError) {
return undefined;
}
// TODO: DESKTOP-8895
if (authError === 'unauthorized') {
return 'This action could not be completed because system authentication failed. Please try again or open the Signal app on your mobile device and go to Backup Settings to view your backup key.';
}
if (authError === 'unauthorized-no-windows-ucv') {
return 'This action could not be completed because Windows Hello is not enabled on your computer. Please set up Windows Hello and try again, or open the Signal app on your mobile device and go to Backup Settings to view your backup key.';
}
return 'The action could not be completed because authentication is not available on this computer. Please open the Signal app on your mobile device and go to Backup Settings to view your backup key.';
}