diff --git a/packages/components/src/components/Badge/Badge.tsx b/packages/components/src/components/Badge/Badge.tsx index 298ca8096a..f38984b8ad 100644 --- a/packages/components/src/components/Badge/Badge.tsx +++ b/packages/components/src/components/Badge/Badge.tsx @@ -5,11 +5,12 @@ import { Icon } from '@suite-common/icons/src/webComponents'; import { borders, Color, CSSColor, spacings, spacingsPx, typography } from '@trezor/theme'; import { focusStyleTransition, getFocusShadowStyle } from '../../utils/utils'; import type { UISize, UIVariant } from '../../config/types'; +import { FrameProps, TransientFrameProps, withFrameProps } from '../common/frameProps'; type BadgeSize = Extract; type BadgeVariant = Extract; -export interface BadgeProps { +export type BadgeProps = FrameProps & { size?: BadgeSize; variant?: BadgeVariant; isDisabled?: boolean; @@ -17,7 +18,8 @@ export interface BadgeProps { hasAlert?: boolean; className?: string; children?: React.ReactNode; -} + inline?: boolean; +}; type MapArgs = { $variant: BadgeVariant; @@ -28,7 +30,8 @@ type BadgeContainerProps = { $size: BadgeSize; $variant: BadgeVariant; $hasAlert: boolean; -}; + $inline: boolean; +} & TransientFrameProps; const mapVariantToBackgroundColor = ({ $variant, theme }: MapArgs): CSSColor => { const colorMap: Record = { @@ -71,7 +74,9 @@ const mapVariantToPadding = ({ $size }: { $size: BadgeSize }): string => { }; const Container = styled.button` - display: flex; + ${withFrameProps} + + display: ${({ $inline }) => ($inline ? 'inline-flex' : 'flex')}; align-items: center; gap: ${spacingsPx.xxs}; padding: ${mapVariantToPadding}; @@ -110,6 +115,8 @@ export const Badge = ({ hasAlert, className, children, + inline, + margin, }: BadgeProps) => { const theme = useTheme(); @@ -120,6 +127,8 @@ export const Badge = ({ disabled={!!isDisabled} $hasAlert={!!hasAlert} className={className} + $margin={margin} + $inline={inline === true} > {icon && ( = { argTypes: { addon: { control: { disable: true } }, items: { control: { disable: true } }, + alignMenu: { + control: { + type: 'select', + }, + options: [ + 'bottom-left', + 'bottom-right', + 'left-bottom', + 'left-top', + 'right-bottom', + 'right-top', + 'top-left', + 'top-right', + ], + }, content: { control: { disable: true } }, className: { control: { disable: true } }, coords: { control: { disable: true } }, diff --git a/packages/components/src/components/typography/Text/Text.tsx b/packages/components/src/components/typography/Text/Text.tsx index 87719a6085..16d77f7aa3 100644 --- a/packages/components/src/components/typography/Text/Text.tsx +++ b/packages/components/src/components/typography/Text/Text.tsx @@ -30,16 +30,12 @@ type ColorProps = { theme: Colors; } & TransientProps; -const getColorForTextVariant = ({ - $variant, - theme, - $color, -}: ColorProps): CSSColor | 'inherit' | string => { +const getColorForTextVariant = ({ $variant, theme, $color }: ColorProps): CSSColor | string => { if ($color !== undefined) { return $color; } - return $variant === undefined ? 'inherit' : theme[variantColorMap[$variant]]; + return theme[$variant !== undefined ? variantColorMap[$variant] : 'textDefault']; }; type StyledTextProps = { diff --git a/packages/suite-analytics/src/types/definitions.ts b/packages/suite-analytics/src/types/definitions.ts index 010c3386ad..a75d11b61b 100644 --- a/packages/suite-analytics/src/types/definitions.ts +++ b/packages/suite-analytics/src/types/definitions.ts @@ -4,7 +4,7 @@ export type OnboardingAnalytics = { startTime: number; firmware: 'install' | 'update' | 'skip' | 'up-to-date'; seed: 'create' | 'recovery' | 'recovery-in-progress'; - seedType: 'standard' | 'shamir'; + seedType: 'shamir-default' | 'shamir-advance' | '12-words' | '24-words'; recoveryType: 'standard' | 'advanced'; backup: 'create' | 'skip'; pin: 'create' | 'skip'; diff --git a/packages/suite/package.json b/packages/suite/package.json index 1468c59a57..96cf5bcc06 100644 --- a/packages/suite/package.json +++ b/packages/suite/package.json @@ -20,6 +20,7 @@ }, "dependencies": { "@everstake/wallet-sdk": "^0.3.40", + "@floating-ui/react": "^0.26.9", "@formatjs/intl": "2.10.0", "@hookform/resolvers": "3.3.4", "@mobily/ts-belt": "^3.13.1", diff --git a/packages/suite/src/support/messages.ts b/packages/suite/src/support/messages.ts index dfb320371f..25aacb9a11 100644 --- a/packages/suite/src/support/messages.ts +++ b/packages/suite/src/support/messages.ts @@ -5062,12 +5062,65 @@ export default defineMessages({ description: 'Used for button triggering seed creation (reset device call), user chooses between single seed and shamir', }, - TR_ONBOARDING_GENERATE_SEED_DESCRIPTION: { - id: 'TR_ONBOARDING_GENERATE_SEED_DESCRIPTION', + TR_ONBOARDING_SELECT_SEED_TYPE: { + id: 'TR_ONBOARDING_SELECT_SEED_TYPE', defaultMessage: - 'Choose how to back up your Trezor. This process will also create a standard wallet for you.', - description: - 'Used for button triggering seed creation (reset device call), user chooses between single seed and shamir', + 'We have selected the optimal setup for backing up your Trezor.', + }, + TR_ONBOARDING_BACKUP_TYPE: { + id: 'TR_ONBOARDING_BACKUP_TYPE', + defaultMessage: 'Backup Type', + }, + TR_ONBOARDING_SEED_TYPE_SINGLE_SEED: { + id: 'TR_ONBOARDING_SEED_TYPE_SINGLE_SEED', + defaultMessage: 'Single seed of 20 words', + }, + TR_ONBOARDING_SEED_TYPE_SINGLE_SEED_DESCRIPTION: { + id: 'TR_ONBOARDING_SEED_TYPE_SINGLE_SEED_DESCRIPTION', + defaultMessage: + 'Allows you to upgrade to multi later, if you decide you want more security.', + }, + TR_ONBOARDING_SEED_TYPE_ADVANCED: { + id: 'TR_ONBOARDING_SEED_TYPE_ADVANCED', + defaultMessage: 'Multiple lists of 20 words', + }, + TR_ONBOARDING_SEED_TYPE_ADVANCED_DESCRIPTION: { + id: 'TR_ONBOARDING_SEED_TYPE_ADVANCED_DESCRIPTION', + defaultMessage: 'Creates multiple pieces of backup. 20 words each.', + }, + TR_ONBOARDING_SEED_TYPE_12_WORDS: { + id: 'TR_ONBOARDING_SEED_TYPE_12_WORDS', + defaultMessage: '12 words', + }, + TR_ONBOARDING_SEED_TYPE_24_WORDS: { + id: 'TR_ONBOARDING_SEED_TYPE_24_WORDS', + defaultMessage: '24 words', + }, + TR_ONBOARDING_BACKUP_TYPE_DEFAULT: { + id: 'TR_ONBOARDING_BACKUP_TYPE_DEFAULT', + defaultMessage: 'Default', + }, + TR_ONBOARDING_BACKUP_TYPE_ADVANCED: { + id: 'TR_ONBOARDING_BACKUP_TYPE_ADVANCED', + defaultMessage: 'Advanced', + }, + TR_ONBOARDING_BACKUP_OLDER_BACKUP_TYPES: { + id: 'TR_ONBOARDING_BACKUP_OLDER_BACKUP_TYPES', + defaultMessage: + 'Older backup types

These don’t allow to upgrade to multi-share later, if you decide you want more security.', + }, + TR_ONBOARDING_BACKUP_LEGACY_WARNING: { + id: 'TR_ONBOARDING_BACKUP_LEGACY_WARNING', + defaultMessage: + 'This backup type won’t allow you to upgrade to multi backup later on if you decide you’d want more security. To allow for backup upgrade, use 20 words backup.', + }, + TR_ONBOARDING_CANNOT_SELECT_SEED_TYPE: { + id: 'TR_ONBOARDING_CANNOT_SELECT_SEED_TYPE', + defaultMessage: 'Todo: user has no option, Trezor One is forced to use 24 words.', + }, + TR_ONBOARDING_SELECT_SEED_TYPE_CONFIRM: { + id: 'TR_ONBOARDING_SELECT_SEED_TYPE_CONFIRM', + defaultMessage: 'Proceed', }, TR_CREATE_WALLET: { id: 'TR_CREATE_WALLET', @@ -5075,25 +5128,6 @@ export default defineMessages({ description: 'Used for button triggering seed creation (reset device call) if shamir/non-shamir selection is not available.', }, - SINGLE_SEED: { - id: 'SINGLE_SEED', - defaultMessage: 'Standard seed backup', - description: 'Basic, non-shamir backup. Seed has only one part.', - }, - SINGLE_SEED_DESCRIPTION: { - id: 'SINGLE_SEED_DESCRIPTION', - defaultMessage: 'Recover your wallet using a single list of English words.', - }, - SHAMIR_SEED: { - id: 'SHAMIR_SEED', - defaultMessage: 'Advanced Shamir Backup', - description: 'Advanced, shamir backup. Seed has multiple parts.', - }, - SHAMIR_SEED_DESCRIPTION: { - id: 'SHAMIR_SEED_DESCRIPTION', - defaultMessage: - 'Recover the wallet by combining lists of words together. These can be secured in different places for added security.', - }, TR_CHECK_FINGERPRINT: { id: 'TR_CHECK_FINGERPRINT', defaultMessage: 'Check fingerprint', diff --git a/packages/suite/src/views/onboarding/index.tsx b/packages/suite/src/views/onboarding/index.tsx index 048434c85f..ec4e62c8ed 100644 --- a/packages/suite/src/views/onboarding/index.tsx +++ b/packages/suite/src/views/onboarding/index.tsx @@ -35,7 +35,7 @@ export const Onboarding = () => { // Selection between a new seed or seed recovery return CreateOrRecover; case STEP.ID_RESET_DEVICE_STEP: - // a) Generating a new seed, selection between single seed or shamir seed (only T2T1 supported) + // a) Generating a new seed, selection between seed types return ResetDeviceStep; case STEP.ID_RECOVERY_STEP: // b) Seed recovery diff --git a/packages/suite/src/views/onboarding/steps/ResetDevice.tsx b/packages/suite/src/views/onboarding/steps/ResetDevice.tsx index 5750c3f544..da7104de31 100644 --- a/packages/suite/src/views/onboarding/steps/ResetDevice.tsx +++ b/packages/suite/src/views/onboarding/steps/ResetDevice.tsx @@ -1,111 +1,152 @@ -import { useState } from 'react'; - +import { useCallback, useEffect, useState } from 'react'; +import styled from 'styled-components'; import { selectDevice } from '@suite-common/wallet-core'; - import * as STEP from 'src/constants/onboarding/steps'; -import { - OnboardingButtonBack, - OnboardingOption, - OptionsWrapper, - OptionsDivider, - OnboardingStepBox, -} from 'src/components/onboarding'; +import { OnboardingButtonBack, OptionsWrapper, OnboardingStepBox } from 'src/components/onboarding'; import { Translation } from 'src/components/suite'; import { useDispatch, useSelector, useOnboarding, useDevice } from 'src/hooks/suite'; import { resetDevice } from 'src/actions/settings/deviceSettingsActions'; import { selectIsActionAbortable } from 'src/reducers/suite/suiteReducer'; +import { Button, Divider, Text } from '@trezor/components'; +import { BackupType, SelectBackupType, defaultBackupTypeMap } from './SelectBackupType'; +import { DeviceModelInternal } from '@trezor/connect'; + +const SelectWrapper = styled.div` + width: 100%; + display: flex; + flex-direction: column; +`; + +const ButtonWrapper = styled.div` + display: flex; + flex-direction: row; + justify-content: center; +`; + +const canChooseBackupType = (device: DeviceModelInternal) => device !== DeviceModelInternal.T1B1; export const ResetDeviceStep = () => { const { isLocked } = useDevice(); - const [submitted, setSubmitted] = useState(false); - const { goToPreviousStep, goToNextStep, updateAnalytics } = useOnboarding(); - const device = useSelector(selectDevice); const isActionAbortable = useSelector(selectIsActionAbortable); + + const deviceModel = device?.features?.internal_model; + + const [submitted, setSubmitted] = useState(false); + const [backupType, setBackupType] = useState( + deviceModel !== undefined ? defaultBackupTypeMap[deviceModel] : 'shamir-default', + ); + const { goToPreviousStep, goToNextStep, updateAnalytics } = useOnboarding(); + const dispatch = useDispatch(); + const isWaitingForConfirmation = + device?.buttonRequests.some( + r => r.code === 'ButtonRequest_ResetDevice' || r.code === 'ButtonRequest_ProtectCall', + ) && !submitted; // ButtonRequest_ResetDevice is for T2T1, ButtonRequest_ProtectCall for T1B1 + + const isDeviceLocked = isLocked(); + + const onResetDevice = useCallback( + async (params?: Parameters[0]) => { + setSubmitted(false); + + const result = await dispatch(resetDevice(params)); + + setSubmitted(true); + + if (result?.success) { + goToNextStep(STEP.ID_SECURITY_STEP); + } + }, + [dispatch, goToNextStep], + ); + + const handleSubmit = useCallback( + async (type: BackupType) => { + switch (type) { + case 'shamir-default': + await onResetDevice({ backup_type: 1 }); // Todo: add number of shards = 1/1 + break; + case 'shamir-advance': + await onResetDevice({ backup_type: 1 }); // Todo: add number of shards = n/m (select on device?) + break; + case '12-words': + await onResetDevice({ backup_type: 0, strength: 128 }); + break; + case '24-words': + await onResetDevice({ backup_type: 0, strength: 256 }); + break; + } + + updateAnalytics({ recoveryType: undefined, seedType: type }); + }, + [updateAnalytics, onResetDevice], + ); + + useEffect(() => { + if (deviceModel !== undefined && !canChooseBackupType(deviceModel)) { + handleSubmit(defaultBackupTypeMap[deviceModel]); + } + }, [deviceModel, handleSubmit]); + // this step expects device if (!device || !device.features) { return null; } - const isShamirBackupAvailable = device.features?.capabilities?.includes('Capability_Shamir'); - const isWaitingForConfirmation = - device.buttonRequests.some( - r => r.code === 'ButtonRequest_ResetDevice' || r.code === 'ButtonRequest_ProtectCall', - ) && !submitted; // ButtonRequest_ResetDevice is for T2T1, ButtonRequest_ProtectCall for T1B1 - - const isDisabled = isLocked(); - - const onResetDevice = async (params?: { backup_type?: 0 | 1 | undefined }) => { - setSubmitted(false); - - const result = await dispatch(resetDevice(params)); - - setSubmitted(true); - - if (result?.success) { - goToNextStep(STEP.ID_SECURITY_STEP); - } - }; - - const handleSingleSeedReset = async () => { - if (isShamirBackupAvailable) { - await onResetDevice({ backup_type: 0 }); - } else { - await onResetDevice(); - } - - updateAnalytics({ recoveryType: undefined, seedType: 'standard' }); - }; - - const handleShamirReset = async () => { - await onResetDevice({ backup_type: 1 }); - - updateAnalytics({ recoveryType: undefined, seedType: 'shamir' }); - }; + const showSelect = !isWaitingForConfirmation && !isDeviceLocked; + const canChoseBackupType = deviceModel !== undefined && canChooseBackupType(deviceModel); return ( } - description={} + description={ + canChoseBackupType ? ( + {chunks}, + }} + /> + ) : ( + + ) + } device={isWaitingForConfirmation ? device : undefined} isActionAbortable={isActionAbortable} outerActions={ - !isWaitingForConfirmation ? ( + showSelect && ( // There is no point to show back button if user can't click it because confirmOnDevice bubble is active goToPreviousStep()} /> - ) : undefined + ) } + variant="small" > - {!isWaitingForConfirmation ? ( - // Show options to chose from only if we are not waiting for confirmation on the device (because that means user has already chosen ) - - } - description={} - /> - - {isShamirBackupAvailable && ( - <> - - } - description={} - /> - - )} + {showSelect ? ( + + + {canChoseBackupType && ( + <> + + + + )} + + + + ) : undefined} diff --git a/packages/suite/src/views/onboarding/steps/SelectBackupType.tsx b/packages/suite/src/views/onboarding/steps/SelectBackupType.tsx new file mode 100644 index 0000000000..0ed71aa469 --- /dev/null +++ b/packages/suite/src/views/onboarding/steps/SelectBackupType.tsx @@ -0,0 +1,348 @@ +import { + Badge, + Divider, + ElevationUp, + Icon, + Radio, + Text, + Warning, + useElevation, +} from '@trezor/components'; +import { + Elevation, + borders, + mapElevationToBackground, + mapElevationToBorder, + spacings, + spacingsPx, + zIndices, +} from '@trezor/theme'; +import { CSSProperties, ReactNode, forwardRef, useState } from 'react'; +import styled from 'styled-components'; +import { useSelector } from '../../../hooks/suite'; +import { selectDevice } from '@suite-common/wallet-core'; +import { DeviceModelInternal } from '@trezor/connect'; +import { + FloatingFocusManager, + autoUpdate, + useFloating, + useClick, + useDismiss, + useRole, + useInteractions, + FloatingPortal, + size, + offset, +} from '@floating-ui/react'; +import { TranslationKey } from '@suite-common/intl-types'; +import { Translation } from '../../../components/suite'; + +export const selectBackupTypes = [ + 'shamir-default', + 'shamir-advance', + '12-words', + '24-words', +] as const; + +export type BackupType = (typeof selectBackupTypes)[number]; + +const isDeviceLegacyMap: Record = { + [DeviceModelInternal.T1B1]: true, + [DeviceModelInternal.T2T1]: true, + [DeviceModelInternal.T2B1]: false, + [DeviceModelInternal.T3T1]: false +}; + +const isLegacyDevice = (model?: DeviceModelInternal) => + model !== undefined && isDeviceLegacyMap[model]; + +export const isShamirBackupType = (type: BackupType) => + ['shamir-default', 'shamir-advance'].includes(type); + +const typesToLabelMap: Record = { + 'shamir-default': 'TR_ONBOARDING_SEED_TYPE_SINGLE_SEED', + 'shamir-advance': 'TR_ONBOARDING_SEED_TYPE_ADVANCED', + '12-words': 'TR_ONBOARDING_SEED_TYPE_12_WORDS', + '24-words': 'TR_ONBOARDING_SEED_TYPE_24_WORDS', +}; + +export const defaultBackupTypeMap: Record = { + [DeviceModelInternal.T1B1]: '24-words', + [DeviceModelInternal.T2T1]: '12-words', + [DeviceModelInternal.T2B1]: 'shamir-default', + [DeviceModelInternal.T3T1]: 'shamir-default' +}; + +const SELECT_ELEMENT_HEIGHT = 84; + +const Wrapper = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: ${spacingsPx.xl}; +`; + +const SelectWrapper = styled.div<{ $elevation: Elevation }>` + width: 100%; + border-radius: ${borders.radii.sm}; + border: 1px solid ${mapElevationToBorder}; + background: ${mapElevationToBackground}; + height: ${SELECT_ELEMENT_HEIGHT}px; + position: relative; +`; + +const OptionText = styled.div` + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; +`; + +const DownIconCircle = styled.div<{ $elevation: Elevation }>` + border-radius: ${borders.radii.full}; + border: 1px solid ${mapElevationToBorder}; + background: ${mapElevationToBackground}; + height: 36px; + width: 36px; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +`; + +const BackupIconWrapper = styled.div``; + +const OptionStyled = styled.div` + display: flex; + flex-direction: row; + padding: ${spacingsPx.md} ${spacingsPx.xl}; + gap: ${spacingsPx.md}; + align-items: center; +`; + +const DownComponent = () => { + const { elevation } = useElevation(); + + return ( + + + + ); +}; + +type SelectedOptionProps = { children: ReactNode; onClick: () => void; isDisabled: boolean }; + +const SelectedOptionStyled = styled.div<{ $isDisabled: boolean }>` + cursor: ${({ $isDisabled }) => ($isDisabled ? undefined : 'pointer')}; +`; + +const SelectedOption = forwardRef( + ({ children, onClick, isDisabled }, ref) => ( + + + + + + {children} + + + + ), +); + +type OptionProps = { + children: ReactNode; + onSelect: () => void; + isChecked: boolean; +}; + +const Option = ({ children, onSelect, isChecked }: OptionProps) => ( + + {children} + + +); + +const FloatingSelectionsWrapper = styled.div<{ $elevation: Elevation }>` + z-index: ${zIndices.modal}; + border-radius: ${borders.radii.sm}; + box-shadow: ${({ theme }) => theme.boxShadowElevated}; + background: ${mapElevationToBackground}; +`; + +type FloatingSelectionsProps = { + selected: BackupType; + onSelect: (value: BackupType) => void; + style: CSSProperties; +}; + +const FloatingSelections = forwardRef( + ({ selected, onSelect, style }, ref) => { + const { elevation } = useElevation(); + + return ( + + + + + + +
}} + /> +
+
+ + +
+ ); + }, +); + +const LegacyWarning = () => { + return ( + + + + ); +}; + +type SelectBackupTypeProps = { + isDisabled: boolean; + selected: BackupType; + onSelect: (value: BackupType) => void; +}; + +export const SelectBackupType = ({ selected, onSelect, isDisabled }: SelectBackupTypeProps) => { + const { elevation } = useElevation(); + const [isOpen, setIsOpen] = useState(false); + const device = useSelector(selectDevice); + + const { refs, floatingStyles, context } = useFloating({ + open: isOpen, + onOpenChange: setIsOpen, + middleware: [ + offset(-SELECT_ELEMENT_HEIGHT), + size({ + apply({ rects, elements }) { + Object.assign(elements.floating.style, { + width: `${rects.reference.width}px`, + }); + }, + padding: 10, + }), + ], + whileElementsMounted: autoUpdate, + }); + + const click = useClick(context); + const dismiss = useDismiss(context); + const role = useRole(context); + + const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]); + + const defaultBackupType: BackupType = device?.features?.internal_model + ? defaultBackupTypeMap[device.features.internal_model] + : 'shamir-default'; + + // We want to show user the 12-words because its default for this device, + // and we want to show user this explicit information, not just default as it is + // for newer devices. + const isModelT = device?.features?.internal_model === DeviceModelInternal.T2T1; + + const isDefault = defaultBackupType === selected && !isModelT; + const isShamirSelected = isShamirBackupType(selected); + const isLegacyModel = isLegacyDevice(device?.features?.internal_model); + + return ( + + + + setIsOpen(true)} + ref={refs.setReference} + {...getReferenceProps()} + > + + + + + + + + + + {isOpen && ( + + + { + setIsOpen(false); + onSelect(value); + }} + /> + + + )} + + + {!isShamirSelected && !isLegacyModel && } + + ); +}; diff --git a/yarn.lock b/yarn.lock index bf5e618c50..5c2a094e5c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11157,6 +11157,7 @@ __metadata: dependencies: "@crowdin/cli": "npm:^3.18.0" "@everstake/wallet-sdk": "npm:^0.3.40" + "@floating-ui/react": "npm:^0.26.9" "@formatjs/cli": "npm:^6.2.7" "@formatjs/intl": "npm:2.10.0" "@hookform/resolvers": "npm:3.3.4"