feat: handle incomaptible FW/Device Models with proper modals for SuiteSync

chore: reactoring to simgle interaction key

add test for fw upgrade needed

chore: add selector test

fix: simplify selector usage
This commit is contained in:
Peter Sanderson
2026-01-19 16:12:50 +01:00
committed by Peter Sanderson
parent 86be8ca6bf
commit 2be4887662
21 changed files with 650 additions and 197 deletions

View File

@@ -1,4 +1,7 @@
import { StaticSessionId } from '@trezor/connect';
import { DesktopSuiteSyncRootState } from './suiteSyncSlice';
export const selectShowEnableSuiteSyncModal = (state: DesktopSuiteSyncRootState): boolean =>
state.suiteSync.showEnableSuiteSyncModal;
export const selectShowEnableSuiteSyncModal = (
state: DesktopSuiteSyncRootState,
): StaticSessionId | null => state.suiteSync.showEnableSuiteSyncModal;

View File

@@ -4,18 +4,19 @@ import {
initialSuiteSyncState as commonInitialState,
suiteSyncReducer,
} from '@suite-common/suite-sync';
import { StaticSessionId } from '@trezor/connect';
import { Action } from 'src/types/suite';
import { STORAGE } from '../suite/constants';
export type DesktopSuiteSyncState = SuiteSyncState & {
showEnableSuiteSyncModal: boolean;
showEnableSuiteSyncModal: StaticSessionId | null;
};
export const initialSuiteSyncState: DesktopSuiteSyncState = {
...commonInitialState,
showEnableSuiteSyncModal: false,
showEnableSuiteSyncModal: null,
};
export type DesktopSuiteSyncRootState = {
@@ -27,7 +28,7 @@ export const suiteSyncSlice = createSliceWithExtraDeps({
initialState: initialSuiteSyncState,
reducers: {
updateShowEnableSuiteSyncModal: (state, action) => {
state.showEnableSuiteSyncModal = action.payload.show;
state.showEnableSuiteSyncModal = action.payload.deviceStaticSessionId;
},
},
extraReducers: builder => {

View File

@@ -1,18 +0,0 @@
import { selectShowEnableSuiteSyncModal } from 'src/actions/suiteSync/suiteSyncSelectors';
import { updateShowEnableSuiteSyncModal } from 'src/actions/suiteSync/suiteSyncSlice';
import { useDispatch, useSelector } from 'src/hooks/suite';
import { TurnOnSecureSyncModal } from './TurnOnSecureSyncModal';
export const LabelingModalManager = () => {
const dispatch = useDispatch();
const showEnableSuiteSyncModal = useSelector(selectShowEnableSuiteSyncModal);
const onClose = () => {
dispatch(updateShowEnableSuiteSyncModal({ show: false }));
};
if (!showEnableSuiteSyncModal) return null;
return <TurnOnSecureSyncModal onClose={onClose} />;
};

View File

@@ -5,19 +5,16 @@ import styled from 'styled-components';
import { Translation } from '@suite/intl';
import {
isSuiteSyncSupportedByDevice,
selectIsSuiteSyncEnabled,
selectShouldOfferSecureSync,
selectIsTurnOnSuiteSyncInteractionNeeded,
} from '@suite-common/suite-sync';
import { selectDeviceByStaticSessionId } from '@suite-common/wallet-core';
import { Button, DropdownMenuItemProps, Row, Text, Tooltip } from '@trezor/components';
import { Button, DropdownMenuItemProps, Row } from '@trezor/components';
import { StaticSessionId } from '@trezor/connect';
import { EditableText, EditableTextProps } from '@trezor/product-components';
import { spacingsPx } from '@trezor/theme';
import { TimerId, exhaustive } from '@trezor/type-utils';
import { addMetadata, init, setEditing } from 'src/actions/suite/metadata/metadataLabelingActions';
import { updateShowEnableSuiteSyncModal } from 'src/actions/suiteSync/suiteSyncSlice';
import { useDiscovery, useDispatch, useSelector } from 'src/hooks/suite';
import {
selectIsLabelingAvailableForEntity,
@@ -25,9 +22,11 @@ import {
} from 'src/reducers/suite/metadataReducer';
import { MetadataAddPayload } from 'src/types/suite/metadata';
import { SuiteSyncInteractionsTooltip } from './SuiteSyncInteractionsTooltip';
import { LabelContentProps, LabelingVariant, MetadataProps, PrimitiveProps } from './definitions';
import { withDropdown } from './withDropdown';
import { withEditable } from './withEditable';
import { updateShowEnableSuiteSyncModal } from '../../../../actions/suiteSync/suiteSyncSlice';
import { processLegacyMetadataIntoSuiteSyncThunk } from '../../../../actions/wallet/processLegacyMetadataIntoSuiteSyncThunk';
import { AccountTypeBadge } from '../../AccountTypeBadge';
import { NO_HIGHLIGHT_ATTRIBUTE } from '../../FindBar/consts';
@@ -360,14 +359,6 @@ export const Labeling = ({
const isSuiteSyncEnabled = useSelector(selectIsSuiteSyncEnabled);
const legacyMetadataState = useSelector(state => state.metadata);
const isLegacyLabelingInitPossible = useSelector(selectIsLabelingInitPossible);
const shouldOfferSecureSync = useSelector(selectShouldOfferSecureSync);
const device = useSelector(state =>
selectDeviceByStaticSessionId(state, deviceStaticSessionId),
);
const hasDeviceSuiteSyncOwner = device?.suiteSyncOwner !== undefined;
const isEvoluLabeling =
isSuiteSyncEnabled && isSuiteSyncSupportedByDevice(device) && hasDeviceSuiteSyncOwner;
const deviceState =
payload.type === 'walletLabel' ? (payload.entityKey as StaticSessionId) : undefined;
@@ -375,6 +366,10 @@ export const Labeling = ({
selectIsLabelingAvailableForEntity(state, payload.entityKey, deviceState),
);
const suiteSyncInteraction = useSelector(state =>
selectIsTurnOnSuiteSyncInteractionNeeded(state, deviceStaticSessionId),
);
const handleEdit = useCallback(async () => {
// When clicking on inline input edit, ensure that everything needed is already ready.
if (
@@ -384,13 +379,13 @@ export const Labeling = ({
// Is there something that needs to be initiated?
!isLegacyLabelingEnabled
) {
if (shouldOfferSecureSync) {
dispatch(updateShowEnableSuiteSyncModal({ show: true }));
if (suiteSyncInteraction !== null) {
dispatch(updateShowEnableSuiteSyncModal({ deviceStaticSessionId }));
// user can decide if they want to enable metadata or not, so we do not set editing state yet
return;
} else {
const result = await dispatch(
return await dispatch(
init(
// Provide force=true argument (user wants to enable metadata).
true,
@@ -398,16 +393,15 @@ export const Labeling = ({
deviceState,
),
);
return result;
}
}
}, [
isSuiteSyncEnabled,
legacyMetadataState.initiating,
isLegacyLabelingEnabled,
shouldOfferSecureSync,
suiteSyncInteraction,
dispatch,
deviceStaticSessionId,
deviceState,
]);
@@ -440,20 +434,28 @@ export const Labeling = ({
}
};
const isSuiteSyncPossible =
suiteSyncInteraction !== 'unsupported' &&
suiteSyncInteraction !== 'firmware-upgrade-needed';
return (
<EditableText
onSubmit={onSubmit ?? handleSubmit}
onEdit={handleEdit}
isDisabled={
isDisabled ||
(!isLegacyLabelingEnabled && !isLegacyLabelingInitPossible && !isEvoluLabeling)
}
isLoading={legacyMetadataState.initiating || isDiscoveryRunning}
data-testid={`@metadata/${payload.type}/${payload.defaultValue}/hover-container`}
{...rest}
>
{children}
</EditableText>
<SuiteSyncInteractionsTooltip suiteSyncInteraction={suiteSyncInteraction}>
<EditableText
onSubmit={onSubmit ?? handleSubmit}
onEdit={handleEdit}
isDisabled={
isDisabled ||
(!isLegacyLabelingEnabled &&
!isLegacyLabelingInitPossible &&
!isSuiteSyncPossible)
}
isLoading={legacyMetadataState.initiating || isDiscoveryRunning}
data-testid={`@metadata/${payload.type}/${payload.defaultValue}/hover-container`}
{...rest}
>
{children}
</EditableText>
</SuiteSyncInteractionsTooltip>
);
};
@@ -482,17 +484,12 @@ export const MetadataLabeling = ({
const isSuiteSyncEnabled = useSelector(selectIsSuiteSyncEnabled);
const legacyMetadataState = useSelector(state => state.metadata);
const device = useSelector(state =>
selectDeviceByStaticSessionId(state, deviceStaticSessionId),
);
const l10nLabelling = getLocalizedActions(payload.type);
const dataTestBase = `@metadata/${payload.type}/${payload.defaultValue}`;
const actionButtonsDisabled = isDiscoveryRunning || pending;
const isSubscribedToSubmitResult = useRef(payload.defaultValue);
const hasDeviceSuiteSyncOwner = device?.suiteSyncOwner !== undefined;
let timeout: TimerId | undefined;
useEffect(() => {
setPending(false);
@@ -511,7 +508,9 @@ export const MetadataLabeling = ({
selectIsLabelingAvailableForEntity(state, payload.entityKey, deviceState),
);
const shouldOfferSecureSync = useSelector(selectShouldOfferSecureSync);
const suiteSyncInteraction = useSelector(state =>
selectIsTurnOnSuiteSyncInteractionNeeded(state, deviceStaticSessionId),
);
// is this concrete instance being edited?
const editActive = legacyMetadataState.editing === payload.defaultValue;
@@ -525,8 +524,8 @@ export const MetadataLabeling = ({
// Is there something that needs to be initiated?
!isLegacyLabelingEnabled
) {
if (shouldOfferSecureSync) {
dispatch(updateShowEnableSuiteSyncModal({ show: true }));
if (suiteSyncInteraction !== null) {
dispatch(updateShowEnableSuiteSyncModal({ deviceStaticSessionId }));
// user can decide if they want to enable metadata or not, so we do not set editing state yet
return;
@@ -614,13 +613,14 @@ export const MetadataLabeling = ({
const labelContainerDataTest = `${dataTestBase}/hover-container`;
const isEvoluLabeling =
isSuiteSyncEnabled && isSuiteSyncSupportedByDevice(device) && hasDeviceSuiteSyncOwner;
const isSuiteSyncPossible =
suiteSyncInteraction !== 'unsupported' &&
suiteSyncInteraction !== 'firmware-upgrade-needed';
// Should "add label"/"edit label" button be visible?
const showActionButton =
!isDisabled &&
(isLegacyLabelingEnabled || isLegacyLabelingInitPossible || isEvoluLabeling) &&
(isLegacyLabelingEnabled || isLegacyLabelingInitPossible || isSuiteSyncPossible) &&
!showSuccess &&
!editActive;
@@ -640,16 +640,7 @@ export const MetadataLabeling = ({
const showEdit = showEditOption(variant);
return (
<Tooltip
content={
isSuiteSyncEnabled &&
!isSuiteSyncSupportedByDevice(device) && (
<Text variant="warning">
<Translation id="FIRMWARE_NEEDS_UPGRADE_FOR_SUITE_SYNC" />
</Text>
)
}
>
<SuiteSyncInteractionsTooltip suiteSyncInteraction={suiteSyncInteraction}>
<LabelContainer
data-testid={labelContainerDataTest}
onClick={e => payload.value && !editActive && e.stopPropagation()}
@@ -707,6 +698,6 @@ export const MetadataLabeling = ({
</Button>
)}
</LabelContainer>
</Tooltip>
</SuiteSyncInteractionsTooltip>
);
};

View File

@@ -0,0 +1,51 @@
import { ReactNode } from 'react';
import { Translation, TranslationKey } from '@suite/intl';
import { SuiteSyncInteraction } from '@suite-common/suite-sync';
import { Text, Tooltip } from '@trezor/components';
import { exhaustive } from '@trezor/type-utils';
type LabelingDisabledTooltipProps = {
suiteSyncInteraction: SuiteSyncInteraction | null;
children: ReactNode;
};
export const SuiteSyncInteractionsTooltip = ({
suiteSyncInteraction,
children,
}: LabelingDisabledTooltipProps) => {
if (suiteSyncInteraction === null) {
return children;
}
const translationMap: Record<'firmware-upgrade-needed' | 'unsupported', TranslationKey> = {
'firmware-upgrade-needed': 'FIRMWARE_NEEDS_UPGRADE_FOR_SUITE_SYNC',
unsupported: 'FIRMWARE_UNSUPPORTED_DEVICE_SUITE_SYNC',
};
switch (suiteSyncInteraction) {
case 'keys-needed':
case 'suite-sync-off':
// For disabled SuiteSync we allow editing as for Legacy devices user can
// turn on the legacy labeling.
return children;
case 'firmware-upgrade-needed':
case 'unsupported':
return (
<Tooltip
content={
<Text variant="warning">
<Translation id={translationMap[suiteSyncInteraction]} />
</Text>
}
>
{children}
</Tooltip>
);
default:
return exhaustive(suiteSyncInteraction);
}
};

View File

@@ -1,93 +0,0 @@
import { Translation } from '@suite/intl';
import { notificationsActions } from '@suite-common/toast-notifications';
import { Card, IconCircle, List, Modal, Paragraph } from '@trezor/components';
import { exhaustive } from '@trezor/type-utils';
import { useDevice, useDispatch } from '../../../hooks/suite';
import { useSuiteServices } from '../../../support/SuiteServicesProvider';
type TurnOnSecureSyncModalProps = {
onClose: () => void;
};
export const TurnOnSecureSyncModal = ({ onClose }: TurnOnSecureSyncModalProps) => {
const { suiteSync } = useSuiteServices();
const dispatch = useDispatch();
const { device } = useDevice();
const onSwitch = async () => {
const result = await suiteSync.turnOnSuiteSync({
deviceStaticSessionId: device?.state?.staticSessionId,
});
if (!result.success) {
const { type } = result.error;
switch (type) {
case 'SuiteSyncUnavailableOnDeviceError':
case 'SuiteSyncFirmwareUpgradeNeededDeviceErrorType':
case 'DeviceCancelled':
case 'DeviceError':
dispatch(notificationsActions.addToast({ type: 'error', error: type }));
return;
default:
return exhaustive(type);
}
}
onClose();
};
return (
<Modal
heading={<Translation id="TR_TURN_ON_SECURE_SYNC_LABELS_MODAL_HEADING" />}
description={<Translation id="TR_TURN_ON_SECURE_SYNC_LABELS_MODAL_DESCRIPTION" />}
onCancel={onClose}
width={600}
bottomContent={
<>
<Modal.Button onClick={onSwitch}>
<Translation id="TR_TURN_ON_SECURE_SYNC" />
</Modal.Button>
<Modal.Button onClick={onClose} intent="neutral" priority="secondary">
<Translation id="TR_CANCEL" />
</Modal.Button>
</>
}
>
<Card paddingType="large">
<List gap={16} variant="tertiary">
<List.Item
bulletComponent={
<IconCircle
name="cloudX"
hasBorder={false}
paddingType="medium"
size={40}
/>
}
>
<Paragraph>
<Translation id="TR_TURN_ON_SECURE_SYNC_DATA_STORED_LOCALLY" />
</Paragraph>
</List.Item>
<List.Item
bulletComponent={
<IconCircle
name="desktopTower"
hasBorder={false}
paddingType="medium"
size={40}
/>
}
>
<Paragraph>
<Translation id="TR_TURN_ON_SECURE_SYNC_ONLY_AUTHORIZED_DEVICES" />
</Paragraph>
</List.Item>
</List>
</Card>
</Modal>
);
};

View File

@@ -0,0 +1,67 @@
import { Translation } from '@suite/intl';
import { selectDeviceByStaticSessionId } from '@suite-common/wallet-core';
import { Card, Modal, Paragraph } from '@trezor/components';
import { StaticSessionId } from '@trezor/connect';
import { getFirmwareVersion } from '@trezor/device-utils';
import { goto } from '../../../../actions/suite/routerActions';
import { useDispatch, useSelector } from '../../../../hooks/suite';
type SuiteSyncFirmwareUpgradeNeededModalProps = {
onClose: () => void;
deviceStaticSessionId: StaticSessionId | null;
};
export const SuiteSyncFirmwareUpgradeNeededModal = ({
onClose,
deviceStaticSessionId,
}: SuiteSyncFirmwareUpgradeNeededModalProps) => {
const dispatch = useDispatch();
const device = useSelector(state =>
deviceStaticSessionId !== null
? selectDeviceByStaticSessionId(state, deviceStaticSessionId)
: undefined,
);
if (device === undefined) {
return null;
}
const currentFwVersion = getFirmwareVersion(device);
const onClick = () => {
// Update will disconnect device in the process and our Firmware Update
// flow won't allow us to navigate back. So we just redirect the user
// and close the modal.
dispatch(goto('firmware-index', { params: { cancelable: true } }));
onClose();
};
return (
<Modal
heading={<Translation id="TR_TURN_ON_SECURE_SYNC_FW_UPDATE_MODAL_HEADING" />}
onCancel={onClose}
width={600}
bottomContent={
<>
<Modal.Button onClick={onClick}>
<Translation id="TR_TURN_ON_SECURE_SYNC_FW_UPDATE_MODAL_UPGRADE" />
</Modal.Button>
<Modal.Button onClick={onClose} intent="neutral" priority="secondary">
<Translation id="TR_TURN_ON_SECURE_SYNC_FW_UPDATE_MODAL_NOT_NOW" />
</Modal.Button>
</>
}
>
<Card paddingType="large">
<Paragraph variant="tertiary">
<Translation
id="TR_TURN_ON_SECURE_SYNC_FW_UPDATE_MODAL_DESCRIPTION"
values={{ version: currentFwVersion }}
/>
</Paragraph>
</Card>
</Modal>
);
};

View File

@@ -0,0 +1,150 @@
import { Translation } from '@suite/intl';
import { isFwUpgradeNeededForSuiteSync } from '@suite-common/suite-sync';
import { notificationsActions } from '@suite-common/toast-notifications';
import { selectDeviceByStaticSessionId } from '@suite-common/wallet-core';
import { Banner, Card, Column, IconCircle, List, Modal, Paragraph } from '@trezor/components';
import { StaticSessionId } from '@trezor/connect';
import { exhaustive } from '@trezor/type-utils';
import { goto } from '../../../../actions/suite/routerActions';
import { useDispatch, useSelector } from '../../../../hooks/suite';
import { useSuiteServices } from '../../../../support/SuiteServicesProvider';
type SuiteSyncTurnOnAndFwUpgradeModalProps = {
deviceStaticSessionId: StaticSessionId;
onClose: () => void;
};
export const SuiteSyncTurnOnAndFwUpgradeModal = ({
deviceStaticSessionId,
onClose,
}: SuiteSyncTurnOnAndFwUpgradeModalProps) => {
const dispatch = useDispatch();
const { suiteSync } = useSuiteServices();
const device = useSelector(state =>
deviceStaticSessionId !== null
? selectDeviceByStaticSessionId(state, deviceStaticSessionId)
: undefined,
);
const isFwUpgradeNeeded = isFwUpgradeNeededForSuiteSync(device);
if (device === undefined) {
return null;
}
const onSwitch = async () => {
const result = await suiteSync.turnOnSuiteSync({
deviceStaticSessionId: deviceStaticSessionId ?? undefined,
});
if (!result.success) {
const { type } = result.error;
switch (type) {
case 'SuiteSyncFirmwareUpgradeNeededDeviceErrorType':
// Update will disconnect device in the process and our Firmware Update
// flow won't allow us to navigate back. So we just redirect the user
// and close the modal.
dispatch(goto('firmware-index', { params: { cancelable: true } }));
onClose();
return;
case 'SuiteSyncUnavailableOnDeviceError':
case 'DeviceCancelled':
case 'DeviceError':
dispatch(notificationsActions.addToast({ type: 'error', error: type }));
return;
default:
return exhaustive(type);
}
}
};
return (
<Modal
heading={<Translation id="TR_TURN_ON_SECURE_SYNC_LABELS_MODAL_HEADING" />}
description={<Translation id="TR_TURN_ON_SECURE_SYNC_LABELS_MODAL_DESCRIPTION" />}
onCancel={onClose}
width={600}
bottomContent={
<>
<Modal.Button onClick={onSwitch}>
<Translation
id={
isFwUpgradeNeeded
? 'TR_TURN_ON_SECURE_SYNC_MODAL_TURN_ON_AND_UPGRADE'
: 'TR_TURN_ON_SECURE_SYNC'
}
/>
</Modal.Button>
<Modal.Button onClick={onClose} intent="neutral" priority="secondary">
<Translation id="TR_CANCEL" />
</Modal.Button>
</>
}
>
<Column gap={16}>
<Card paddingType="large">
<List gap={16} variant="tertiary">
<List.Item
bulletComponent={
<IconCircle
name="tag"
hasBorder={false}
paddingType="medium"
size={40}
/>
}
>
<Paragraph>
<Translation id="TR_TURN_ON_SECURE_SYNC_DATA_LABELS" />
</Paragraph>
</List.Item>
<List.Item
bulletComponent={
<IconCircle
name="cloudX"
hasBorder={false}
paddingType="medium"
size={40}
/>
}
>
<Paragraph>
<Translation id="TR_TURN_ON_SECURE_SYNC_DATA_STORED_LOCALLY" />
</Paragraph>
</List.Item>
<List.Item
bulletComponent={
<IconCircle
name="desktopTower"
hasBorder={false}
paddingType="medium"
size={40}
/>
}
>
<Paragraph>
<Translation id="TR_TURN_ON_SECURE_SYNC_ONLY_AUTHORIZED_DEVICES" />
</Paragraph>
</List.Item>
</List>
</Card>
{isFwUpgradeNeeded && (
<Banner
intent="info"
icon="password"
description={
<Paragraph typographyStyle="hint">
<Translation id="TR_NEEDS_ATTENTION_FIRMWARE_REQUIRED" />
</Paragraph>
}
/>
)}
</Column>
</Modal>
);
};

View File

@@ -0,0 +1,50 @@
import { selectIsTurnOnSuiteSyncInteractionNeeded } from '@suite-common/suite-sync';
import { exhaustive } from '@trezor/type-utils';
import { useDispatch, useSelector } from 'src/hooks/suite';
import { SuiteSyncFirmwareUpgradeNeededModal } from './SuiteSyncFirmwareUpgradeNeededModal';
import { SuiteSyncTurnOnAndFwUpgradeModal } from './SuiteSyncTurnOnAndFwUpgradeModal';
import { selectShowEnableSuiteSyncModal } from '../../../../actions/suiteSync/suiteSyncSelectors';
import { updateShowEnableSuiteSyncModal } from '../../../../actions/suiteSync/suiteSyncSlice';
export const TurnOnSuiteSyncModalManager = () => {
const dispatch = useDispatch();
const deviceStaticSessionId = useSelector(selectShowEnableSuiteSyncModal);
const suiteSyncInteraction = useSelector(state =>
selectIsTurnOnSuiteSyncInteractionNeeded(state, deviceStaticSessionId),
);
if (deviceStaticSessionId === null || suiteSyncInteraction === null) {
return null;
}
const onClose = () => {
dispatch(updateShowEnableSuiteSyncModal({ deviceStaticSessionId: null }));
};
switch (suiteSyncInteraction) {
case 'unsupported': // This modal is not relevant to unsupported devices.
case 'keys-needed':
return null;
case 'suite-sync-off':
return (
<SuiteSyncTurnOnAndFwUpgradeModal
onClose={onClose}
deviceStaticSessionId={deviceStaticSessionId}
/>
);
case 'firmware-upgrade-needed':
return (
<SuiteSyncFirmwareUpgradeNeededModal
onClose={onClose}
deviceStaticSessionId={deviceStaticSessionId}
/>
);
default:
return exhaustive(suiteSyncInteraction);
}
};

View File

@@ -7,7 +7,7 @@ import { useDispatch, usePreferredModal, useSelector } from '../../../../hooks/s
import type { AppState, ForegroundAppRoute } from '../../../../types/suite';
import { SwitchDevice } from '../../../../views/suite/SwitchDevice/SwitchDevice';
import { ThpGlobalModalManager } from '../../../connection/thp/ThpGlobalModalManager';
import { LabelingModalManager } from '../../labeling/LabelingModalManager';
import { TurnOnSuiteSyncModalManager } from '../../labeling/TurnOnSuiteSync/TurnOnSuiteSyncModalManager';
import { ConfirmPassphraseBeforeAction } from '../../modals/ReduxModal/DeviceContextModal/ConfirmPassphraseBeforeAction';
import { PassphraseModal } from '../../modals/ReduxModal/DeviceContextModal/PassphraseModal';
import { PassphraseOnDeviceModal } from '../../modals/ReduxModal/DeviceContextModal/PassphraseOnDeviceModal';
@@ -57,11 +57,13 @@ const ForegroundAppModal = ({ app, cancelable }: ForegroundAppModalProps) => {
const onCancel = () => dispatch(closeModalApp());
// IMPORTANT: This is the place where all the modals that need to rendered OVER
// Wallet-Switch needs to be.
if (app === 'switch-device') {
return (
<>
<SwitchDevice cancelable={cancelable} onCancel={onCancel} />
<LabelingModalManager />
<TurnOnSuiteSyncModalManager />
{/* THP flow can be triggered by auto-connect and that will open THP modals.
* However, this ForegroundApp takes precedes and prevents ALL other modals
* to render. So we have to render it here as well.*/}

View File

@@ -118,8 +118,8 @@ export const SuiteLayout = ({ children, 'data-testid': dataTest }: SuiteLayoutPr
<ModalSwitcher />
<PassphraseFlow />
<AppShortcuts />
<AppShortcuts />
<PowerMonitorManager />
{isBelowTablet && <CoinjoinBars />}
@@ -154,7 +154,6 @@ export const SuiteLayout = ({ children, 'data-testid': dataTest }: SuiteLayoutPr
</Columns>
</Body>
</LayoutContext.Provider>
{!isBelowTablet && <GuideButton />}
</Modal.Provider>
</PageWrapper>

View File

@@ -4,7 +4,7 @@ import { usePreferredModal } from 'src/hooks/suite/usePreferredModal';
import { ForegroundAppModal } from './ForegroundAppModal';
import { WipedBleDeviceNeedsManualOsRemovalModalManager } from '../../bluetooth/WipedBleDeviceNeedsManualOsRemovalModalManager';
import { LabelingModalManager } from '../../labeling/LabelingModalManager';
import { TurnOnSuiteSyncModalManager } from '../../labeling/TurnOnSuiteSync/TurnOnSuiteSyncModalManager';
import { ReduxModal } from '../ReduxModal/ReduxModal';
type ModalParams = ReturnType<typeof usePreferredModal>;
@@ -33,8 +33,8 @@ export const ModalSwitcher = () => {
<>
<WipedBleDeviceNeedsManualOsRemovalModalManager />
<ThpGlobalModalManager />
<TurnOnSuiteSyncModalManager />
<ConnectionGlobalModalManager />
<LabelingModalManager />
<Inner modal={modal} />
</>
);

View File

@@ -27,6 +27,7 @@ import { useLabelingDeviceState } from 'src/hooks/suite/useLabelingDeviceState';
import { useSuiteServices } from 'src/support/SuiteServicesProvider';
import { useLegacyAnalytics } from 'src/support/useAnalytics';
import { updateShowEnableSuiteSyncModal } from '../../../actions/suiteSync/suiteSyncSlice';
import { LabelingSwitchToLegacyModal } from '../../../components/suite/labeling/LabelingSwitchToLegacyModal';
export const Labeling = () => {
@@ -44,6 +45,9 @@ export const Labeling = () => {
const legacyMetadataState = useSelector(state => state.metadata);
if (deviceStaticSessionId === undefined) {
return null;
}
const legacyEnableIfNeeded = () => {
if (!legacyMetadataState.enabled) {
suiteSync.turnOffSuiteSync(); // Enabling Legacy Labeling implicitly disables Evolu
@@ -80,8 +84,11 @@ export const Labeling = () => {
if (!result.success) {
const { type } = result.error;
switch (type) {
case 'SuiteSyncUnavailableOnDeviceError':
case 'SuiteSyncFirmwareUpgradeNeededDeviceErrorType':
dispatch(updateShowEnableSuiteSyncModal({ deviceStaticSessionId }));
return;
case 'SuiteSyncUnavailableOnDeviceError':
case 'DeviceCancelled':
case 'DeviceError':
dispatch(notificationsActions.addToast({ type: 'error', error: type }));

View File

@@ -20,15 +20,18 @@ export const SuiteSyncWalletDebug = ({ device }: { device: AcquiredDevice }) =>
const legacyMetadataState = useSelector(state => state.metadata);
if (
!isSuiteSyncDebugEnabled ||
!isSuiteSyncSupportedByDevice(device) ||
!device.state?.staticSessionId
) {
const deviceStaticSessionId = device.state?.staticSessionId;
const isSuiteSyncDebug =
isSuiteSyncDebugEnabled &&
isSuiteSyncSupportedByDevice(device) &&
deviceStaticSessionId !== undefined;
if (!isSuiteSyncDebug) {
return;
}
const { walletDescriptor, deviceId } = parseDeviceStaticSessionId(device.state.staticSessionId);
const { walletDescriptor, deviceId } = parseDeviceStaticSessionId(deviceStaticSessionId);
const handleResetKeysRequest = () => {
if (!device?.id || device.state?.staticSessionId === undefined) {

View File

@@ -2,9 +2,10 @@ export {
selectIsSuiteSyncEnabled,
selectIsFeatureSuiteSyncAvailable,
selectSuiteSyncRelayUrl,
selectShouldOfferSecureSync,
selectIsTurnOnSuiteSyncInteractionNeeded,
selectIsSuiteSyncDebugEnabled,
} from './suiteSyncSelectors';
export type { SuiteSyncInteraction } from './suiteSyncSelectors';
export type { WithSuiteSyncAndDeviceState } from './suiteSyncSelectors';
export { createSuiteSyncCompositionRoot } from './createSuiteSyncCompositionRoot';
export { suiteSyncReducer, initialSuiteSyncState } from './suiteSyncReducer';
@@ -34,4 +35,4 @@ export {
findSuiteSyncAccountLabel,
} from './data/suiteSyncDataSelectors';
export { suiteSyncToBip329 } from './data/labeling/suiteSyncToBip329';
export { isSuiteSyncSupportedByDevice } from './suiteSyncUtils';
export { isSuiteSyncSupportedByDevice, isFwUpgradeNeededForSuiteSync } from './suiteSyncUtils';

View File

@@ -24,6 +24,10 @@ export const createEnsureWalletSuiteSyncOn =
async ({ deviceStaticSessionId }) => {
const device = selectDeviceByStaticSessionId(deps.getState(), deviceStaticSessionId);
if (isFwUpgradeNeededForSuiteSync(device)) {
return err({ type: 'SuiteSyncFirmwareUpgradeNeededDeviceErrorType' });
}
const canTurnOnSuiteSync =
device && isTrezorDeviceWithState(device) && isSuiteSyncSupportedByDevice(device);
@@ -31,9 +35,5 @@ export const createEnsureWalletSuiteSyncOn =
return err({ type: 'SuiteSyncUnavailableOnDeviceError' });
}
if (isFwUpgradeNeededForSuiteSync(device)) {
return err({ type: 'SuiteSyncFirmwareUpgradeNeededDeviceErrorType' });
}
return await deps.ensureSuiteSyncData({ deviceStaticSessionId });
};

View File

@@ -71,6 +71,26 @@ describe(createEnsureWalletSuiteSyncOn.name, () => {
expect(deps.ensureSuiteSyncData).not.toHaveBeenCalled();
});
it('returns error when device needs firmware upgrade', async () => {
const unavailableCapabilities: UnavailableCapabilities = { evolu: 'update-required' };
const deps = createMockDeps<EnsureWalletSuiteSyncOnDeps>({
dispatch: null,
getState: () => createMockState([createDevice({ unavailableCapabilities })]),
refreshSuiteSyncKeys: null,
ensureSuiteSyncData: null,
subscriptionStorage: createSubscriptionStorageMock(),
});
const ensureWalletSuiteSyncOn = createEnsureWalletSuiteSyncOn(deps);
const result = await ensureWalletSuiteSyncOn({ deviceStaticSessionId });
expect(!result.success && result.error.type).toBe(
'SuiteSyncFirmwareUpgradeNeededDeviceErrorType',
);
expect(deps.ensureSuiteSyncData).not.toHaveBeenCalled();
});
it('calls ensureSuiteSyncData when wallet is eligible', async () => {
const ensureResult = ok({ data: {} } as any);

View File

@@ -1,6 +1,8 @@
import { DeviceRootState } from '@suite-common/wallet-core';
import { DeviceRootState, selectDeviceByStaticSessionId } from '@suite-common/wallet-core';
import { StaticSessionId } from '@trezor/connect';
import { SuiteSyncState } from './suiteSyncReducer';
import { isFwUpgradeNeededForSuiteSync, isSuiteSyncSupportedByDevice } from './suiteSyncUtils';
export type WithSuiteSyncState = {
suiteSync: SuiteSyncState;
@@ -20,7 +22,45 @@ export const selectIsFeatureSuiteSyncAvailable = (state: WithSuiteSyncAndDeviceS
export const selectSuiteSyncRelayUrl = (state: WithSuiteSyncAndDeviceState) =>
state.suiteSync.settings.suiteSyncRelayUrl;
export const selectShouldOfferSecureSync = (state: WithSuiteSyncAndDeviceState): boolean =>
state.device.selectedDevice?.unavailableCapabilities?.evolu === undefined &&
state.suiteSync.settings.isFeatureSuiteSyncAvailable &&
!state.suiteSync.settings.isSuiteSyncEnabled;
export type SuiteSyncInteraction =
| 'suite-sync-off'
| 'firmware-upgrade-needed'
| 'unsupported'
| 'keys-needed';
export const selectIsTurnOnSuiteSyncInteractionNeeded = (
state: WithSuiteSyncAndDeviceState,
deviceStaticSessionId: StaticSessionId | null,
): SuiteSyncInteraction | null => {
if (deviceStaticSessionId === null) {
return null;
}
const device = selectDeviceByStaticSessionId(state, deviceStaticSessionId);
if (device === undefined) {
return null;
}
if (!selectIsFeatureSuiteSyncAvailable(state)) {
return null;
}
if (!selectIsSuiteSyncEnabled(state)) {
return 'suite-sync-off';
}
if (!isSuiteSyncSupportedByDevice(device)) {
return 'unsupported';
}
if (isFwUpgradeNeededForSuiteSync(device)) {
return 'firmware-upgrade-needed';
}
if (device.suiteSyncOwner === null) {
return 'keys-needed';
}
return null;
};

View File

@@ -0,0 +1,151 @@
import { asEncryptedHex } from '@suite-common/platform-encryption';
import type { SuiteSyncOwnerSerialized, TrezorDevice } from '@suite-common/suite-types';
import { getSuiteDevice } from '@suite-common/test-utils';
import { deviceReducerInitialState } from '@suite-common/wallet-core';
import type { UnavailableCapabilities } from '@trezor/connect';
import { StaticSessionId } from '@trezor/connect';
import { initialSuiteSyncState } from '../suiteSyncReducer';
import { selectIsTurnOnSuiteSyncInteractionNeeded } from '../suiteSyncSelectors';
import type { WithSuiteSyncAndDeviceState } from '../suiteSyncSelectors';
const deviceStaticSessionId: StaticSessionId = '1@2:3';
const createDevice = (overrides: Partial<TrezorDevice> = {}): TrezorDevice =>
({
...getSuiteDevice(),
id: 'device-id',
state: {
staticSessionId: deviceStaticSessionId,
},
unavailableCapabilities: {},
...overrides,
}) as unknown as TrezorDevice;
const createMockState = (
deviceOverrides: Partial<TrezorDevice> = {},
suiteSyncOverrides: Partial<WithSuiteSyncAndDeviceState['suiteSync']> = {},
): WithSuiteSyncAndDeviceState => ({
device: {
...deviceReducerInitialState,
devices: [createDevice(deviceOverrides)],
},
suiteSync: {
...initialSuiteSyncState,
...suiteSyncOverrides,
},
});
describe(selectIsTurnOnSuiteSyncInteractionNeeded.name, () => {
it('no interaction needed if device is not found', () => {
const state = createMockState();
state.device.devices = [];
const result = selectIsTurnOnSuiteSyncInteractionNeeded(state, deviceStaticSessionId);
expect(result).toBeNull();
});
it('no interaction, if suite-sync it not enabled in debug/experimental features', () => {
const state = createMockState(
{},
{
settings: {
...initialSuiteSyncState.settings,
isFeatureSuiteSyncAvailable: false,
},
},
);
const result = selectIsTurnOnSuiteSyncInteractionNeeded(state, deviceStaticSessionId);
expect(result).toBeNull();
});
it('interaction is "suite-sync-off" when Suite Sync is disabled', () => {
const state = createMockState(
{},
{
settings: {
...initialSuiteSyncState.settings,
isFeatureSuiteSyncAvailable: true,
isSuiteSyncEnabled: false,
},
},
);
const result = selectIsTurnOnSuiteSyncInteractionNeeded(state, deviceStaticSessionId);
expect(result).toBe('suite-sync-off');
});
it('interaction is "unsupported" when device does not support Suite Sync (T1, TT)', () => {
const unavailableCapabilities: UnavailableCapabilities = { evolu: 'no-support' };
const state = createMockState(
{ unavailableCapabilities },
{
settings: {
...initialSuiteSyncState.settings,
isFeatureSuiteSyncAvailable: true,
isSuiteSyncEnabled: true,
},
},
);
const result = selectIsTurnOnSuiteSyncInteractionNeeded(state, deviceStaticSessionId);
expect(result).toBe('unsupported');
});
it('interaction is "firmware-upgrade-needed" when device needs firmware upgrade', () => {
const unavailableCapabilities: UnavailableCapabilities = { evolu: 'update-required' };
const state = createMockState(
{ unavailableCapabilities },
{
settings: {
...initialSuiteSyncState.settings,
isFeatureSuiteSyncAvailable: true,
isSuiteSyncEnabled: true,
},
},
);
const result = selectIsTurnOnSuiteSyncInteractionNeeded(state, deviceStaticSessionId);
expect(result).toBe('firmware-upgrade-needed');
});
it('interaction is "keys-needed" when device has suiteSyncOwner set', () => {
const state = createMockState(
{ suiteSyncOwner: null },
{
settings: {
...initialSuiteSyncState.settings,
isFeatureSuiteSyncAvailable: true,
isSuiteSyncEnabled: true,
},
},
);
const result = selectIsTurnOnSuiteSyncInteractionNeeded(state, deviceStaticSessionId);
expect(result).toBe('keys-needed');
});
it('no interaction needed', () => {
const state = createMockState(
{ suiteSyncOwner: asEncryptedHex<SuiteSyncOwnerSerialized>('owner-key') },
{
settings: {
...initialSuiteSyncState.settings,
isFeatureSuiteSyncAvailable: true,
isSuiteSyncEnabled: true,
},
},
);
const result = selectIsTurnOnSuiteSyncInteractionNeeded(state, deviceStaticSessionId);
expect(result).toBeNull();
});
});

View File

@@ -30,8 +30,6 @@ export const createSuiteSyncNativeCompositionRoot = (
...deps,
createSuiteStorage: createEvoluStorage,
createSuiteSyncOwner: evoluCreateSuiteSyncOwner,
flushSuiteSyncStorage: () => {
reloadAppAsync();
},
flushSuiteSyncStorage: reloadAppAsync,
});
};

View File

@@ -6163,6 +6163,11 @@ export const messages = defineMessages({
id: 'FIRMWARE_NEEDS_UPGRADE_FOR_SUITE_SYNC',
defaultMessage: "Upgrade your Trezor's firmware to use Suite Sync.",
},
FIRMWARE_UNSUPPORTED_DEVICE_SUITE_SYNC: {
id: 'FIRMWARE_UNSUPPORTED_DEVICE_SUITE_SYNC',
defaultMessage:
'Suite Sync works with Trezor Safe 3, Trezor Safe 5, and Trezor Safe 7 devices.',
},
TR_DISABLED_SWITCH_TOOLTIP: {
id: 'TR_DISABLED_SWITCH_TOOLTIP',
defaultMessage: 'Connect & unlock device to change',
@@ -10484,10 +10489,35 @@ export const messages = defineMessages({
id: 'TR_TURN_ON_SECURE_SYNC_DATA_STORED_LOCALLY',
defaultMessage: 'Your data is stored locally and only synced with devices youve approved.',
},
TR_TURN_ON_SECURE_SYNC_DATA_LABELS: {
id: 'TR_TURN_ON_SECURE_SYNC_DATA_LABELS',
defaultMessage: 'Name your wallets, personalize accounts, and label transactions. ',
},
TR_TURN_ON_SECURE_SYNC_ONLY_AUTHORIZED_DEVICES: {
id: 'TR_TURN_ON_SECURE_SYNC_ONLY_AUTHORIZED_DEVICES',
defaultMessage: 'Only devices youve authorized through your Trezor can decrypt your data.',
},
TR_TURN_ON_SECURE_SYNC_FW_UPDATE_MODAL_HEADING: {
id: 'TR_TURN_ON_SECURE_SYNC_FW_UPDATE_MODAL_HEADING',
defaultMessage: 'Firmware update needed for Suite Sync',
},
TR_TURN_ON_SECURE_SYNC_FW_UPDATE_MODAL_DESCRIPTION: {
id: 'TR_TURN_ON_SECURE_SYNC_FW_UPDATE_MODAL_DESCRIPTION',
defaultMessage:
'The current firmware version on your Trezor is {version}. Update the firmware to use Suite Sync.',
},
TR_TURN_ON_SECURE_SYNC_MODAL_TURN_ON_AND_UPGRADE: {
id: 'TR_TURN_ON_SECURE_SYNC_MODAL_TURN_ON_AND_UPGRADE',
defaultMessage: 'Turn on & update firmware',
},
TR_TURN_ON_SECURE_SYNC_FW_UPDATE_MODAL_UPGRADE: {
id: 'TR_TURN_ON_SECURE_SYNC_FW_UPDATE_MODAL_UPGRADE',
defaultMessage: 'Upgrade',
},
TR_TURN_ON_SECURE_SYNC_FW_UPDATE_MODAL_NOT_NOW: {
id: 'TR_TURN_ON_SECURE_SYNC_FW_UPDATE_MODAL_NOT_NOW',
defaultMessage: 'Not now',
},
TR_SWITCH_TO_LEGACY_LABELING_MODAL_HEADING: {
id: 'TR_SWITCH_TO_LEGACY_LABELING_MODAL_HEADING',
defaultMessage: 'Switch to legacy labeling?',