diff --git a/apps/next/src/localization/locales/en/translation.json b/apps/next/src/localization/locales/en/translation.json index 4e75f73d2..42511c7ba 100644 --- a/apps/next/src/localization/locales/en/translation.json +++ b/apps/next/src/localization/locales/en/translation.json @@ -26,14 +26,17 @@ "Accounts updated": "Accounts updated", "Action was not approved. Please try again.": "Action was not approved. Please try again.", "Activity": "Activity", + "Add Authenticator": "Add Authenticator", "Add Avalanche C-Chain Address": "Add Avalanche C-Chain Address", "Add Avalanche X/P-Chain Address": "Add Avalanche X/P-Chain Address", "Add Bitcoin Address": "Add Bitcoin Address", "Add Custom Token": "Add Custom Token", + "Add Passkey": "Add Passkey", "Add RPC URL": "Add RPC URL", "Add Solana": "Add Solana", "Add Solana Address": "Add Solana Address", "Add Token": "Add Token", + "Add Yubikey": "Add Yubikey", "Add a display name for your wallet. You can change it at any time in the settings.": "Add a display name for your wallet. You can change it at any time in the settings.", "Add a name so that it is easier to find later.": "Add a name so that it is easier to find later.", "Add an account or connect a wallet": "Add an account or connect a wallet", @@ -44,6 +47,7 @@ "Add next": "Add next", "Add optional recovery methods": "Add optional recovery methods", "Add optional recovery methods to securely restore access in case you lose your credentials.": "Add optional recovery methods to securely restore access in case you lose your credentials.", + "Add recovery method": "Add recovery method", "Add token name": "Add token name", "Add token symbol": "Add token symbol", "Add using Keystone": "Add using Keystone", @@ -67,16 +71,20 @@ "Approves all {{token}}": "Approves all {{token}}", "Approving will give this dApp access to your active account.": "Approving will give this dApp access to your active account.", "Are you sure that you want to cancel this request?": "Are you sure that you want to cancel this request?", + "Are you sure you want to change the authenticator?": "Are you sure you want to change the authenticator?", "Are you sure you want to delete selected accounts?": "Are you sure you want to delete selected accounts?", "Are you sure you want to delete this contact?": "Are you sure you want to delete this contact?", "Are you sure you want to delete this network?": "Are you sure you want to delete this network?", "Are you sure you want to delete {{name}} account?": "Are you sure you want to delete {{name}} account?", + "Are you sure you want to remove this recovery method?": "Are you sure you want to remove this recovery method?", "Are you sure you want to reset the RPC URL?": "Are you sure you want to reset the RPC URL?", "As a Core user, you have the option to opt-in for airdrop rewards based on your activity and engagement. Core will collect anonymous interaction data to power this feature.": "As a Core user, you have the option to opt-in for airdrop rewards based on your activity and engagement. Core will collect anonymous interaction data to power this feature.", "Assets": "Assets", "Attempted to use an unknown derivation path": "Attempted to use an unknown derivation path", "Authenticator": "Authenticator", + "Authenticator added!": "Authenticator added!", "Authenticator app": "Authenticator app", + "Authenticator app removed successfully!": "Authenticator app removed successfully!", "Authenticator apps generate secure, time-based codes for wallet recovery.": "Authenticator apps generate secure, time-based codes for wallet recovery.", "Auto": "Auto", "Auto • {{name}}": "Auto • {{name}}", @@ -109,6 +117,7 @@ "Cancelling...": "Cancelling...", "Chain": "Chain", "Chain ID": "Chain ID", + "Change Authenticator App": "Change Authenticator App", "Change password": "Change password", "Chinese - Simplified": "Chinese - Simplified", "Chinese - Traditional": "Chinese - Traditional", @@ -200,12 +209,14 @@ "Enter a valid email address": "Enter a valid email address", "Enter a value": "Enter a value", "Enter chain ID": "Enter chain ID", + "Enter code manually": "Enter code manually", "Enter explorer URL": "Enter explorer URL", "Enter header name": "Enter header name", "Enter logo URL": "Enter logo URL", "Enter name": "Enter name", "Enter password": "Enter password", "Enter private key": "Enter private key", + "Enter the code generated from the authenticator app": "Enter the code generated from the authenticator app", "Enter the code generated from your authenticator app.": "Enter the code generated from your authenticator app.", "Enter the code generated in your authenticator app.": "Enter the code generated in your authenticator app.", "Enter token name": "Enter token name", @@ -217,12 +228,14 @@ "Enter your password to view your recovery phrase": "Enter your password to view your recovery phrase", "Enter your recovery phrase": "Enter your recovery phrase", "Enter your recovery phrase to import a wallet": "Enter your recovery phrase to import a wallet", + "Error occurred. Please try again.": "Error occurred. Please try again.", "Error while deriving address": "Error while deriving address", "Exit": "Exit", "Explorer URL": "Explorer URL", "Export Cancelled": "Export Cancelled", "Export Failed": "Export Failed", "Extended public key not found": "Extended public key not found", + "FIDO device removed!": "FIDO device removed!", "Failed to Initialize": "Failed to Initialize", "Failed to add network": "Failed to add network", "Failed to change account": "Failed to change account", @@ -245,6 +258,7 @@ "File Upload Failed": "File Upload Failed", "Fill out your wallet details": "Fill out your wallet details", "Filter": "Filter", + "Finish setting up recovery methods": "Finish setting up recovery methods", "Flagged as malicious. Disconnect now!": "Flagged as malicious. Disconnect now!", "Floating": "Floating", "Follow the instructions in your browser window to add this key to your account.": "Follow the instructions in your browser window to add this key to your account.", @@ -385,6 +399,7 @@ "No custom headers are configured.": "No custom headers are configured.", "No matching results": "No matching results", "No matching sites found": "No matching sites found", + "No recovery methods set up": "No recovery methods set up", "No routes found with enough liquidity.": "No routes found with enough liquidity.", "No saved addresses": "No saved addresses", "No thanks": "No thanks", @@ -393,6 +408,7 @@ "Only Keystore files from the Avalanche Wallet are supported": "Only Keystore files from the Avalanche Wallet are supported", "Only keystore files exported from the Avalanche Wallet are supported.": "Only keystore files exported from the Avalanche Wallet are supported.", "Oops! It seems like you have no internet connection. Please try again later.": "Oops! It seems like you have no internet connection. Please try again later.", + "Open any authenticator app and scan the QR code below or enter the code manually": "Open any authenticator app and scan the QR code below or enter the code manually", "Open the Solana app on your Ledger device": "Open the Solana app on your Ledger device", "Open the {{appName}} app on your Ledger device in order to continue with this transaction": "Open the {{appName}} app on your Ledger device in order to continue with this transaction", "Options": "Options", @@ -433,6 +449,7 @@ "Please try again later or choose a different token pair.": "Please try again later or choose a different token pair.", "Please try again later or contact support.": "Please try again later or contact support.", "Please try again later.": "Please try again later.", + "Please try again.": "Please try again.", "Please try switching to a different network.": "Please try switching to a different network.", "Please update the Avalanche Application on your Ledger device to continue.": "Please update the Avalanche Application on your Ledger device to continue.", "Please update the {{appName}} app on your Ledger device to {{version}} or higher to be able to continue": "Please update the {{appName}} app on your Ledger device to {{version}} or higher to be able to continue", @@ -470,7 +487,9 @@ "Recovery phrase copied to clipboard": "Recovery phrase copied to clipboard", "Refresh": "Refresh", "Reject": "Reject", + "Remove FIDO Device": "Remove FIDO Device", "Remove account": "Remove account", + "Remove recovery method": "Remove recovery method", "Rename": "Rename", "Rename Account": "Rename Account", "Rename Wallet": "Rename Wallet", @@ -522,6 +541,8 @@ "Sending this token is not supported yet.": "Sending this token is not supported yet.", "Sending this type of token is not supported by Core": "Sending this type of token is not supported by Core", "Set a limit that you will allow to automatically spend.": "Set a limit that you will allow to automatically spend.", + "Set up ": "Set up ", + "Setting up your authenticator app.": "Setting up your authenticator app.", "Settings": "Settings", "Show me Trending Tokens": "Show me Trending Tokens", "Show password": "Show password", @@ -536,7 +557,9 @@ "Solana": "Solana", "Some of the required parameters are invalid.": "Some of the required parameters are invalid.", "Some of the required parameters are missing.": "Some of the required parameters are missing.", + "Something Went Wrong": "Something Went Wrong", "Something bad happened please try again later!": "Something bad happened please try again later!", + "Something went wrong": "Something went wrong", "Something went wrong. Please try again.": "Something went wrong. Please try again.", "Sort": "Sort", "Spanish": "Spanish", @@ -545,6 +568,7 @@ "Stay updated on latest airdrops, events and more! You can unsubscribe anytime. For more details, see our Privacy Policy": "Stay updated on latest airdrops, events and more! You can unsubscribe anytime. For more details, see our Privacy Policy", "Storage update failed": "Storage update failed", "Strong password! Keep this one!": "Strong password! Keep this one!", + "Success!": "Success!", "Successfully imported the keystore file.": "Successfully imported the keystore file.", "Successfully swapped {{srcAmount}} {{srcToken}} to {{destAmount}} {{destToken}}": "Successfully swapped {{srcAmount}} {{srcToken}} to {{destAmount}} {{destToken}}", "Suggested slippage - your transaction will fail if the fail price changes unfavorable more than this percentage": "Suggested slippage - your transaction will fail if the fail price changes unfavorable more than this percentage", @@ -658,6 +682,7 @@ "Unsupported network": "Unsupported network", "Unsupported token type": "Unsupported token type", "Update Required": "Update Required", + "Update your recovery methods": "Update your recovery methods", "Updating accounts...": "Updating accounts...", "Upload a JSON file to import": "Upload a JSON file to import", "Upload keystore file": "Upload keystore file", @@ -687,6 +712,7 @@ "Wallet not found": "Wallet not found", "Wallet renamed": "Wallet renamed", "Wallet secrets not found for the requested ID": "Wallet secrets not found for the requested ID", + "We encountered an unexpected issue.": "We encountered an unexpected issue.", "We were not able to verify this code. Please try again.": "We were not able to verify this code. Please try again.", "We're unable to cover the gas fees for your transaction at this time. As a result, this feature has been disabled.": "We're unable to cover the gas fees for your transaction at this time. As a result, this feature has been disabled.", "Weak password! Try adding more characters": "Weak password! Try adding more characters", @@ -698,6 +724,7 @@ "You are about to exit the recovery phrase export process. Are you sure you want to exit?": "You are about to exit the recovery phrase export process. Are you sure you want to exit?", "You can now close this window and continue using Core.": "You can now close this window and continue using Core.", "You can now start buying, swapping, sending, receiving crypto and collectibles with no added fees": "You can now start buying, swapping, sending, receiving crypto and collectibles with no added fees", + "You cannot add a new recovery method for your wallet! Try again later!": "You cannot add a new recovery method for your wallet! Try again later!", "You do not have enough funds to cover the network fees.": "You do not have enough funds to cover the network fees.", "You may need to enable popups to continue, you can find this setting near the address bar.": "You may need to enable popups to continue, you can find this setting near the address bar.", "You must allow access to scan the QR code.": "You must allow access to scan the QR code.", @@ -705,6 +732,8 @@ "You pay": "You pay", "You receive": "You receive", "You will be prompted {{remaining}} more time(s).": "You will be prompted {{remaining}} more time(s).", + "You will no longer be able to use this authenticator once you switch. You will need to re-add an authenticator": "You will no longer be able to use this authenticator once you switch. You will need to re-add an authenticator", + "You will no longer be able to use this method once you removed.": "You will no longer be able to use this method once you removed.", "You're about to terminate this session": "You're about to terminate this session", "Your account's private key is a fixed password for accessing the\n specific addresses above. Keep it secure, anyone with this private key\n can access the account associated with it.": "Your account's private key is a fixed password for accessing the\n specific addresses above. Keep it secure, anyone with this private key\n can access the account associated with it.", "Your device is now connected to Core!": "Your device is now connected to Core!", diff --git a/apps/next/src/pages/Onboarding/components/CardMenu.tsx b/apps/next/src/pages/Onboarding/components/CardMenu.tsx index 881a9f6b3..6a219ec0e 100644 --- a/apps/next/src/pages/Onboarding/components/CardMenu.tsx +++ b/apps/next/src/pages/Onboarding/components/CardMenu.tsx @@ -7,6 +7,7 @@ import { StackProps, styled, Typography, + useTheme, } from '@avalabs/k2-alpine'; import { FC, ReactNode, type ReactElement } from 'react'; import { useHistory } from 'react-router-dom'; @@ -26,12 +27,14 @@ type CardMenuItemProps = { icon: ReactElement; text: string; description?: string; + size?: string; + itemGap?: string; } & ( | { link: string; } | { - onClick: () => void; + onClick?: () => void; } ); @@ -39,22 +42,36 @@ export const CardMenuItem: FC = ({ icon, text, description, + size, ...props }) => { const history = useHistory(); + const theme = useTheme(); const onClick = - 'onClick' in props ? props.onClick : () => history.push(props.link); + 'onClick' in props + ? props.onClick + : 'link' in props + ? () => history.push(props.link) + : undefined; return ( - + {icon} - {text} + + {text} + {description && ( @@ -78,10 +95,7 @@ export const CardMenuItem: FC = ({ const CardMenuItemContainer = styled(MenuItem)(({ theme }) => ({ flexDirection: 'row', justifyContent: 'space-between', - gap: theme.spacing(3), color: theme.palette.text.primary, - paddingLeft: theme.spacing(2.5), - paddingRight: theme.spacing(2.5), transition: 'background-color .15s ease-in-out', '& .CardLikeMenuItem-chevron': { diff --git a/apps/next/src/pages/Onboarding/flows/SeedlessFlow/screens/SeedlessChooseSetupMethod.tsx b/apps/next/src/pages/Onboarding/flows/SeedlessFlow/screens/SeedlessChooseSetupMethod.tsx index 23ed9e37d..937cb39a2 100644 --- a/apps/next/src/pages/Onboarding/flows/SeedlessFlow/screens/SeedlessChooseSetupMethod.tsx +++ b/apps/next/src/pages/Onboarding/flows/SeedlessFlow/screens/SeedlessChooseSetupMethod.tsx @@ -1,7 +1,13 @@ import { FC, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { MdOutlinePassword } from 'react-icons/md'; -import { Divider, EncryptedIcon, Stack, StackProps } from '@avalabs/k2-alpine'; +import { + Divider, + EncryptedIcon, + SecurityKeyIcon, + Stack, + StackProps, +} from '@avalabs/k2-alpine'; import { FullscreenModalActions, @@ -63,7 +69,7 @@ export const SeedlessChooseSetupMethod: FC = ({ /> onMethodChosen('yubikey')} - icon={} // TODO: replace with YubiKey icon + icon={} text={t('Yubikey')} description={t( `Use a YubiKey for physical, hardware-based protection and strong authentication.`, diff --git a/apps/next/src/pages/Onboarding/flows/SeedlessFlow/screens/SeedlessNameFidoKey.tsx b/apps/next/src/pages/Onboarding/flows/SeedlessFlow/screens/SeedlessNameFidoKey.tsx index cf97c5da7..f7431d9cd 100644 --- a/apps/next/src/pages/Onboarding/flows/SeedlessFlow/screens/SeedlessNameFidoKey.tsx +++ b/apps/next/src/pages/Onboarding/flows/SeedlessFlow/screens/SeedlessNameFidoKey.tsx @@ -15,11 +15,13 @@ import { NavButton } from '@/pages/Onboarding/components/NavButton'; type SeedlessNameFidoKeyProps = { keyType: 'passkey' | 'yubikey'; onNext: (name: string) => void; + required?: boolean; }; export const SeedlessNameFidoKey: FC = ({ keyType, onNext, + required, }) => { const { t } = useTranslation(); const [name, setName] = useState(''); @@ -50,9 +52,11 @@ export const SeedlessNameFidoKey: FC = ({ - onNext('')}> - {t(`Skip`)} - + {!required && ( + onNext('')}> + {t(`Skip`)} + + )} { @@ -15,6 +18,16 @@ export const Settings: FC = () => { + + + { + const theme = useTheme(); const { t } = useTranslation(); const { lockWallet } = useSettingsContext(); const { isDeveloperMode, setDeveloperMode } = useNetworkContext(); @@ -53,6 +58,10 @@ export const SettingsHomePage = () => { const [isPrivacyMode, setIsPrivacyMode] = useState(false); const [isCoreAiEnabled, setIsCoreAiEnabled] = useState(false); const { showTrendingTokens, setShowTrendingTokens } = useSettingsContext(); + const { isMfaSetupPromptVisible } = useSeedlessMfaManager(); + const { featureFlags } = useFeatureFlagContext(); + const isMfaSettingsAvailable = + featureFlags[FeatureGates.SEEEDLESS_MFA_SETTINGS]; return ( { )} /> + + {isMfaSetupPromptVisible && ( + + + {t('Set up ')} + + } + labelVariant="subtitle3" + descriptionVariant="caption2" + sx={{ borderBottom: 'none' }} + /> + + )} + { divider href={`${path}/change-password`} /> - {(walletDetails?.type === SecretType.Mnemonic || - walletDetails?.type === SecretType.Seedless) && ( - - )} + {!isMfaSetupPromptVisible && + (walletDetails?.type === SecretType.Mnemonic || + walletDetails?.type === SecretType.Seedless) && ( + + )} - + + {walletDetails?.type === SecretType.Seedless && + isMfaSettingsAvailable && ( + + )} + & OwnProps; export const SettingsNavItem: FC = ({ @@ -20,6 +26,8 @@ export const SettingsNavItem: FC = ({ children, href, secondaryAction, + labelVariant, + descriptionVariant, ...props }) => { const history = useHistory(); @@ -56,10 +64,10 @@ export const SettingsNavItem: FC = ({ { + const { t } = useTranslation(); + const history = useHistory(); + const { initAuthenticatorChange, completeAuthenticatorChange } = + useSeedlessMfaManager(); + const [totpChallenge, setTotpChallenge] = useState(); + const [showSecret, setShowSecret] = useState(false); + const [screenState, setScreenState] = useState( + AuthenticatorState.Initial, + ); + const [code, setCode] = useState(''); + const [isSubmitted, setIsSubmitted] = useState(false); + const [error, setError] = useState(); + + const goBack = useGoBack(); + + const initChange = useCallback(async () => { + try { + const challenge = await initAuthenticatorChange(); + + setTotpChallenge(challenge); + setScreenState(AuthenticatorState.Initiated); + } catch { + setTotpChallenge(undefined); + setScreenState(AuthenticatorState.Failure); + } + }, [initAuthenticatorChange]); + + useEffect(() => { + initChange(); + }, [initChange]); + + const totpSecret = useMemo(() => { + if (!totpChallenge) { + return ''; + } + + return new URL(totpChallenge.totpUrl).searchParams.get('secret') ?? ''; + }, [totpChallenge]); + + const headline = { + [AuthenticatorState.Initial]: t('Scan QR code'), + [AuthenticatorState.Initiated]: t('Scan QR code'), + [AuthenticatorState.VerifyCode]: t('Verify code'), + [AuthenticatorState.Failure]: t('Something went wrong'), + }; + + const description = { + [AuthenticatorState.Initial]: t('Setting up your authenticator app.'), + [AuthenticatorState.Initiated]: t( + 'Open any authenticator app and scan the QR code below or enter the code manually', + ), + [AuthenticatorState.VerifyCode]: t( + 'Enter the code generated from the authenticator app', + ), + }; + + const onCodeSubmit = useCallback(async () => { + setIsSubmitted(true); + if (!totpChallenge) { + setScreenState(AuthenticatorState.Failure); + return; + } + try { + await completeAuthenticatorChange(totpChallenge.totpId, code); + toast.success(t('Authenticator added!')); + history.push('/settings/recovery-methods'); + } catch (e) { + setError(e as AuthErrorCode); + } finally { + setIsSubmitted(false); + } + }, [code, completeAuthenticatorChange, history, t, totpChallenge]); + + return ( + + {description[screenState]} + {screenState === AuthenticatorState.Initial && ( + + )} + {screenState === AuthenticatorState.Initiated && + !showSecret && + totpChallenge && ( + setScreenState(AuthenticatorState.VerifyCode)} + onShowSecret={() => setShowSecret(true)} + /> + )} + {screenState === AuthenticatorState.Initiated && showSecret && ( + setScreenState(AuthenticatorState.VerifyCode)} + /> + )} + {totpChallenge && screenState === AuthenticatorState.VerifyCode && ( + { + setCode(c); + setError(undefined); + }} + error={error} + onSubmit={onCodeSubmit} + isSubmitted={isSubmitted} + /> + )} + {screenState === AuthenticatorState.Failure && } + + ); +}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/Authenticator/AuthenticatorDetails.tsx b/apps/next/src/pages/Settings/components/RecoveryMethods/Authenticator/AuthenticatorDetails.tsx new file mode 100644 index 000000000..a8c0e42e3 --- /dev/null +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/Authenticator/AuthenticatorDetails.tsx @@ -0,0 +1,23 @@ +import { getIconForMethod } from '../RecoveryMethodCard'; +import { CardMenuItem } from '@/pages/Onboarding/components/CardMenu'; + +export enum AuthenticatorState { + Initial = 'initial', + Initiated = 'initiated', + ConfirmChange = 'confirm-change', + ConfirmRemoval = 'confirm-removal', + Pending = 'pending', + Completing = 'completing', + VerifyCode = 'verify-code', + Failure = 'failure', +} + +export const AuthenticatorDetails = ({ method, methodName }) => { + return ( + + ); +}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/Authenticator/AuthenticatorVerifyCode.tsx b/apps/next/src/pages/Settings/components/RecoveryMethods/Authenticator/AuthenticatorVerifyCode.tsx new file mode 100644 index 000000000..df3d56877 --- /dev/null +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/Authenticator/AuthenticatorVerifyCode.tsx @@ -0,0 +1,60 @@ +import { + Button, + Paper, + Stack, + styled, + toast, + Typography, +} from '@avalabs/k2-alpine'; +import { useTranslation } from 'react-i18next'; + +const CodePaper = styled(Paper)(() => ({ + borderRadius: 2, + overflow: 'hidden', + width: '100%', + flexDirection: 'row', + px: 1.5, + py: 2, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + mt: 4, +})); + +export const AuthenticatorVerifyCode = ({ totpSecret, onNext }) => { + const { t } = useTranslation(); + + return ( + + + + {totpSecret} + + + + {/** TODO: Put the description sections with ICONS after they put in the k2 alpine */} + + + ); +}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/Authenticator/AuthenticatorVerifyScreen.tsx b/apps/next/src/pages/Settings/components/RecoveryMethods/Authenticator/AuthenticatorVerifyScreen.tsx new file mode 100644 index 000000000..8aea432aa --- /dev/null +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/Authenticator/AuthenticatorVerifyScreen.tsx @@ -0,0 +1,45 @@ +import { Button, Stack, useTheme } from '@avalabs/k2-alpine'; +import QRCode from 'qrcode.react'; +import { useTranslation } from 'react-i18next'; + +export const AuthenticatorVerifyScreen = ({ + totpChallenge, + onShowSecret, + onNext, +}) => { + const theme = useTheme(); + const { t } = useTranslation(); + + return ( + + + + + + + + + + ); +}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/Authenticator/AuthenticatorVerifyTotp.tsx b/apps/next/src/pages/Settings/components/RecoveryMethods/Authenticator/AuthenticatorVerifyTotp.tsx new file mode 100644 index 000000000..e661e50c1 --- /dev/null +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/Authenticator/AuthenticatorVerifyTotp.tsx @@ -0,0 +1,49 @@ +import { TotpCodeField } from '@/components/TotpCodeField'; +import { Button, Stack } from '@avalabs/k2-alpine'; +import { AuthErrorCode } from '@core/types'; +import { useKeyboardShortcuts, useTotpErrorMessage } from '@core/ui'; +import { useTranslation } from 'react-i18next'; + +interface AuthenticatorVerifyTotpProps { + onChange: (code: string) => void; + error?: AuthErrorCode; + onSubmit?: () => void; + isSubmitted?: boolean; +} +export const AuthenticatorVerifyTotp = ({ + onChange, + error, + onSubmit, + isSubmitted, +}: AuthenticatorVerifyTotpProps) => { + const totpError = useTotpErrorMessage(error); + const keyboardShortcuts = useKeyboardShortcuts({ + Enter: () => onSubmit?.(), + }); + const { t } = useTranslation(); + + return ( + + { + onChange(e.target.value); + }} + {...keyboardShortcuts} + /> + {onSubmit && ( + + )} + + ); +}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/Authenticator/index.ts b/apps/next/src/pages/Settings/components/RecoveryMethods/Authenticator/index.ts new file mode 100644 index 000000000..82c26be9a --- /dev/null +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/Authenticator/index.ts @@ -0,0 +1 @@ +export * from './Authenticator'; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/ConfiguredMethodList.tsx b/apps/next/src/pages/Settings/components/RecoveryMethods/ConfiguredMethodList.tsx new file mode 100644 index 000000000..3cf4cb874 --- /dev/null +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/ConfiguredMethodList.tsx @@ -0,0 +1,55 @@ +import { useTranslation } from 'react-i18next'; +import { RecoveryMethodCard } from './RecoveryMethodCard'; +import { useAnalyticsContext } from '@core/ui'; +import { RecoveryMethodScreen } from './RecoveryMethods'; +import { Paper } from '@avalabs/k2-alpine'; + +export const ConfiguredMethodList = ({ + existingRecoveryMethods, + setSelectedMethod, + setScreen, +}) => { + const { t } = useTranslation(); + const { capture } = useAnalyticsContext(); + + return ( + + {existingRecoveryMethods.map((method) => { + if (method.type === 'totp') { + return ( + { + capture('ConfigureTotpClicked'); + setSelectedMethod(method); + setScreen(RecoveryMethodScreen.Method); + }} + /> + ); + } + + return ( + { + capture('ConfigureFidoClicked'); + setSelectedMethod(method); + setScreen(RecoveryMethodScreen.Method); + }} + /> + ); + })} + + ); +}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/FIDO/FIDO.tsx b/apps/next/src/pages/Settings/components/RecoveryMethods/FIDO/FIDO.tsx new file mode 100644 index 000000000..1f92cc3ae --- /dev/null +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/FIDO/FIDO.tsx @@ -0,0 +1,122 @@ +import { Page } from '@/components/Page'; +import { Button, Stack, toast, Typography } from '@avalabs/k2-alpine'; +import { + useConnectionContext, + useKeyboardShortcuts, + useSeedlessMfaManager, + useTotpErrorMessage, +} from '@core/ui'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useHistory, useLocation, useParams } from 'react-router-dom'; +import { useMFAEvents } from '../../common/useMFAEvent'; +import { AuthErrorCode, ExtensionRequest, MfaResponseData } from '@core/types'; +import { TotpCodeField } from '@/components/TotpCodeField'; +import { SubmitMfaResponseHandler } from '~/services/seedless/handlers/submitMfaResponse'; +import { InProgress } from '../../RecoveryPhrase/components/ShowPhrase/components/InProgress'; + +export const FIDO = () => { + const history = useHistory(); + const { removeFidoDevice } = useSeedlessMfaManager(); + const { t } = useTranslation(); + const [error, setError] = useState(); + const totpError = useTotpErrorMessage(error); + const [code, setCode] = useState(''); + const [isVerifying, setIsVerifying] = useState(false); + + const { request } = useConnectionContext(); + + const mfaEvents = useMFAEvents(setError); + + const { id } = useParams<{ id: string }>(); + const { hash } = useLocation(); + + const submitButtonRef = useRef(null); + const keyboardShortcuts = useKeyboardShortcuts({ + Enter: () => submitButtonRef.current?.click(), + }); + + const deviceId = `${id}${hash}`; + + const remove = useCallback(async () => { + try { + await removeFidoDevice(deviceId); + toast.success(t('FIDO device removed!')); + } catch { + toast.error(t('Error occurred. Please try again.')); + } finally { + history.push('/settings/recovery-methods'); + } + }, [deviceId, history, removeFidoDevice, t]); + + useEffect(() => { + remove(); + }, [remove]); + + const submitCode = useCallback( + async (params: MfaResponseData) => { + setIsVerifying(true); + + try { + await request({ + method: ExtensionRequest.SEEDLESS_SUBMIT_MFA_RESPONSE, + params: [params], + }); + } catch { + setError(AuthErrorCode.TotpVerificationError); + } finally { + setIsVerifying(false); + } + }, + [request], + ); + return ( + + + {t( + 'Open any authenticator app and scan the QR code below or enter the code manually', + )} + + {(!mfaEvents || !mfaEvents.challenge) && } + {mfaEvents.challenge && mfaEvents.challenge.type === 'totp' && ( + + { + setCode(e.target.value); + setError(undefined); + }} + /> + + + )} + + ); +}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/FIDO/FIDODetails.tsx b/apps/next/src/pages/Settings/components/RecoveryMethods/FIDO/FIDODetails.tsx new file mode 100644 index 000000000..4b3d7cf91 --- /dev/null +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/FIDO/FIDODetails.tsx @@ -0,0 +1,19 @@ +import { CardMenuItem } from '@/pages/Onboarding/components/CardMenu'; +import { getIconForMethod } from '../RecoveryMethodCard'; + +export enum FIDOState { + Initial = 'initial', + Initiated = 'initiated', + ConfirmRemoval = 'confirm-removal', + Failure = 'failure', +} + +export const FIDODetails = ({ method }) => { + return ( + + ); +}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/FullScreens/AddFIDO.tsx b/apps/next/src/pages/Settings/components/RecoveryMethods/FullScreens/AddFIDO.tsx new file mode 100644 index 000000000..b07fda44d --- /dev/null +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/FullScreens/AddFIDO.tsx @@ -0,0 +1,64 @@ +import { useSeedlessMfaManager } from '@core/ui'; +import { useCallback, useState } from 'react'; +import { MFA } from '../../RecoveryPhrase/components/ShowPhrase/components/SeedlessFlow/pages/MFA'; +import { Stack, toast, Typography } from '@avalabs/k2-alpine'; +import { useTranslation } from 'react-i18next'; +import { SeedlessNameFidoKey } from '@/pages/Onboarding/flows/SeedlessFlow/screens'; +import { KeyType } from '@core/types'; +import { useHistory } from 'react-router-dom'; + +export enum AddFIDOState { + Initial = 'initial', + Initiated = 'initiated', + Failure = 'failure', +} + +export const AddFIDO = ({ keyType }: { keyType: KeyType }) => { + const { t } = useTranslation(); + const { addFidoDevice } = useSeedlessMfaManager(); + const history = useHistory(); + + const [screenState, setScreenState] = useState( + AddFIDOState.Initial, + ); + + const registerFidoKey = useCallback( + async (deviceName: string) => { + try { + await addFidoDevice(deviceName, keyType); + toast.success(t(`${deviceName} (${keyType}) added!`), { + duration: Infinity, + }); + history.push('/update-recovery-method'); + return; + } catch { + setScreenState(AddFIDOState.Failure); + } + }, + [addFidoDevice, history, keyType, t], + ); + + return ( + + {screenState === AddFIDOState.Initial && ( + { + setScreenState(AddFIDOState.Initiated); + registerFidoKey(deviceName); + }} + /> + )} + {screenState === AddFIDOState.Initiated && } + + {screenState === AddFIDOState.Failure && ( + + + {t('Error occurred. Please try again.')} + + + )} + + ); +}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/FullScreens/AddTotp.tsx b/apps/next/src/pages/Settings/components/RecoveryMethods/FullScreens/AddTotp.tsx new file mode 100644 index 000000000..496e18461 --- /dev/null +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/FullScreens/AddTotp.tsx @@ -0,0 +1,111 @@ +import { AuthErrorCode, TotpResetChallenge } from '@core/types'; +import { useSeedlessMfaManager } from '@core/ui'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { MFA } from '../../RecoveryPhrase/components/ShowPhrase/components/SeedlessFlow/pages/MFA'; +import { Button, Stack, toast } from '@avalabs/k2-alpine'; +import { AuthenticatorVerifyTotp } from '../Authenticator/AuthenticatorVerifyTotp'; +import { AuthenticatorState } from '../Authenticator/AuthenticatorDetails'; +import { useTranslation } from 'react-i18next'; +import { + FullscreenModalActions, + FullscreenModalDescription, + FullscreenModalTitle, +} from '@/components/FullscreenModal'; +import { SeedlessTotpQRCode } from '@/pages/Onboarding/flows/SeedlessFlow/screens'; +import { RecoveryMethodFailure } from '../components/RecoveryMethodFailure'; +import { useHistory } from 'react-router-dom'; + +export const AddTotp = () => { + const [error, setError] = useState(); + const history = useHistory(); + const { t } = useTranslation(); + + const { initAuthenticatorChange, completeAuthenticatorChange } = + useSeedlessMfaManager(); + + const [totpChallenge, setTotpChallenge] = useState(); + + const [screenState, setScreenState] = useState( + AuthenticatorState.Initiated, + ); + + const [code, setCode] = useState(''); + + const submitButtonRef = useRef(null); + const [isVerifying, setIsVerifying] = useState(false); + + const initChange = useCallback(async () => { + try { + const challenge = await initAuthenticatorChange(); + setTotpChallenge(challenge); + setScreenState(AuthenticatorState.Pending); + } catch { + setTotpChallenge(undefined); + setScreenState(AuthenticatorState.Failure); + } + }, [initAuthenticatorChange]); + + const onVerify = useCallback(async () => { + if (!totpChallenge) { + return; + } + try { + setIsVerifying(true); + await completeAuthenticatorChange(totpChallenge.totpId, code); + toast.success(t('Authenticator added!'), { + duration: Infinity, + }); + history.push('update-recovery-method'); + } catch (e) { + setError(e as AuthErrorCode); + } finally { + setIsVerifying(false); + } + }, [code, completeAuthenticatorChange, history, t, totpChallenge]); + + useEffect(() => { + initChange(); + }, [initChange]); + + return ( + + {!totpChallenge && } + {screenState === AuthenticatorState.Pending && totpChallenge && ( + setScreenState(AuthenticatorState.VerifyCode)} + /> + )} + {screenState === AuthenticatorState.VerifyCode && totpChallenge && ( + <> + {t('Verify Code')} + + {t('Enter the code generated from your authenticator app.')} + + { + setCode(c); + setError(undefined); + }} + error={error} + /> + + + + + )} + {screenState === AuthenticatorState.Failure && } + + ); +}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/FullScreens/DefaultContent.tsx b/apps/next/src/pages/Settings/components/RecoveryMethods/FullScreens/DefaultContent.tsx new file mode 100644 index 000000000..1443d3c10 --- /dev/null +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/FullScreens/DefaultContent.tsx @@ -0,0 +1,68 @@ +import { + FullscreenModalActions, + FullscreenModalTitle, +} from '@/components/FullscreenModal'; +import { + Button, + PasswordIcon, + SecurityKeyIcon, + Stack, +} from '@avalabs/k2-alpine'; +import { useSeedlessMfaManager } from '@core/ui'; +import { useTranslation } from 'react-i18next'; +import { useHistory } from 'react-router-dom'; + +export const DefaultContent = () => { + const { t } = useTranslation(); + const history = useHistory(); + const { hasTotpConfigured, isLoadingRecoveryMethods } = + useSeedlessMfaManager(); + return ( + <> + + {t('Update your recovery methods')} + + + + {!isLoadingRecoveryMethods && !hasTotpConfigured && ( + + )} + + + + + + ); +}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/FullScreens/FullScreenContent.tsx b/apps/next/src/pages/Settings/components/RecoveryMethods/FullScreens/FullScreenContent.tsx new file mode 100644 index 000000000..cd28277e9 --- /dev/null +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/FullScreens/FullScreenContent.tsx @@ -0,0 +1,59 @@ +import { RemoveTotp } from './RemoveTotp'; +import { FullscreenModalTitle } from '@/components/FullscreenModal'; +import { RecoveryMethodsFullScreenParams } from './RecoveryMethodsFullScreen'; +import { AddTotp } from './AddTotp'; +import { AddFIDO } from './AddFIDO'; +import { DefaultContent } from './DefaultContent'; +import { KeyType } from '@core/types'; + +export type RecoveryMethodPages = + | 'defaultContent' + | 'removeTOTP' + | 'addTOTP' + | 'addFIDO'; + +export type FullScreenContentProps = { + [page in RecoveryMethodPages]: React.ReactNode; +}; + +export const FullScreenContent = ({ + mfaType, + action, + keyType, +}: RecoveryMethodsFullScreenParams) => { + const getPage = () => { + if (mfaType === 'totp' && action === 'remove') { + return 'removeTOTP'; + } + if (mfaType === 'totp' && action === 'add') { + return 'addTOTP'; + } + if (mfaType === 'fido' && action === 'add') { + return 'addFIDO'; + } + return 'defaultContent'; + }; + + const page = getPage(); + + const headline = {}; + const content: FullScreenContentProps = { + removeTOTP: , + addTOTP: , + addFIDO: keyType ? ( + + ) : ( + + ), + defaultContent: , + }; + + return ( + <> + {headline[page] && ( + {headline[page]} + )} + {content[page]} + + ); +}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/FullScreens/RecoveryMethodsFullScreen.tsx b/apps/next/src/pages/Settings/components/RecoveryMethods/FullScreens/RecoveryMethodsFullScreen.tsx new file mode 100644 index 000000000..d021ee17d --- /dev/null +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/FullScreens/RecoveryMethodsFullScreen.tsx @@ -0,0 +1,48 @@ +import { useHistory, useLocation, useParams } from 'react-router-dom'; + +import { FullscreenModal } from '@/components/FullscreenModal'; +import { FullscreenAnimatedBackground } from '@/components/FullscreenAnimatedBackground'; + +import { FullScreenContent } from './FullScreenContent'; +import { useOpenApp } from '@/hooks/useOpenApp'; + +export interface RecoveryMethodsFullScreenParams { + mfaType?: 'totp' | 'fido'; + keyType?: 'yubikey' | 'passkey'; + action?: 'remove' | 'add'; +} + +export const RecoveryMethodsFullScreen = () => { + const history = useHistory(); + + const homeURL = '/update-recovery-method'; + const location = useLocation(); + const openApp = useOpenApp(); + + const { mfaType, action, keyType } = + useParams(); + + return ( + <> + + { + if (location.pathname === homeURL) { + openApp(); + } + history.push(homeURL); + }} + > + + + + ); +}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/FullScreens/RemoveTotp.tsx b/apps/next/src/pages/Settings/components/RecoveryMethods/FullScreens/RemoveTotp.tsx new file mode 100644 index 000000000..5f0e1dd3e --- /dev/null +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/FullScreens/RemoveTotp.tsx @@ -0,0 +1,149 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + AlertCircleIcon, + Button, + CheckCircleIcon, + Stack, + toast, + Typography, +} from '@avalabs/core-k2-components'; + +import { useSeedlessMfaManager } from '@core/ui'; +import { AuthErrorCode, MfaRequestType } from '@core/types'; +import { useMFAEvents } from '../../common/useMFAEvent'; +import { FIDOChallenge } from '../../common/FIDOChallenge'; +import { useHistory } from 'react-router-dom'; +import { InProgress } from '../../RecoveryPhrase/components/ShowPhrase/components/InProgress'; + +enum RemoveTotpState { + Loading = 'loading', + IncompatibleWallet = 'incompatible-wallet', + NameYourDevice = 'name-your-device', + AddAuthenticator = 'add-authenticator', + Success = 'success', + Failure = 'failure', +} + +export const RemoveTotp = () => { + const history = useHistory(); + const { t } = useTranslation(); + const { removeTotp } = useSeedlessMfaManager(); + const [state, setState] = useState(RemoveTotpState.Loading); + const [error, setError] = useState(); + const mfaChallenge = useMFAEvents(setError); + + const remove = useCallback(async () => { + try { + await removeTotp(); + setState(RemoveTotpState.Success); + toast.success('Recovery method removed!', { duration: 20000 }); + history.push('update-recovery-method'); + } catch { + setState(RemoveTotpState.Failure); + } + }, [history, removeTotp]); + + useEffect(() => { + remove(); + }, [remove]); + + return ( + + {(!mfaChallenge || !mfaChallenge.challenge) && ( + + + + )} + {state === RemoveTotpState.Failure && ( + + + + + {t('Something Went Wrong')} + + + {t('We encountered an unexpected issue.')} + + {t('Please try again.')} + + + + + + )} + {state === RemoveTotpState.Success && ( + + + {t('Success!')} + + {t('Authenticator app removed successfully!')} + + + + + )} + {state === RemoveTotpState.Loading && ( + <> + {mfaChallenge.challenge?.type === MfaRequestType.Fido && ( + + )} + + )} + + ); +}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/RecoveryMethod.tsx b/apps/next/src/pages/Settings/components/RecoveryMethods/RecoveryMethod.tsx new file mode 100644 index 000000000..7ee0fb842 --- /dev/null +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/RecoveryMethod.tsx @@ -0,0 +1,109 @@ +import { Button, Paper, Stack } from '@avalabs/k2-alpine'; +import { useSeedlessMfaManager } from '@core/ui'; +import { useTranslation } from 'react-i18next'; +import { AuthenticatorDetails } from './Authenticator/AuthenticatorDetails'; +import { FIDODetails } from './FIDO/FIDODetails'; +import { RecoveryMethod as RecoveryMethodType } from '@core/types'; +import { useCallback, useState } from 'react'; +import { openFullscreenTab } from '@core/common'; +import { useHistory } from 'react-router-dom'; +import { ConfirmPage } from './components/ConfirmPage'; + +interface RecoveryMethodProps { + method: RecoveryMethodType; +} + +const openRemoveTotpPopup = () => + openFullscreenTab('update-recovery-method/totp/remove'); + +const openAddTotpPopup = () => + openFullscreenTab('update-recovery-method/totp/add'); + +export const RecoveryMethod = ({ method }: RecoveryMethodProps) => { + const { t } = useTranslation(); + const { hasTotpConfigured, hasFidoConfigured, recoveryMethods } = + useSeedlessMfaManager(); + const history = useHistory(); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + const [isChangeOpen, setIsChangeOpen] = useState(false); + + const isRemovable = + recoveryMethods.length > 1 && hasFidoConfigured && hasTotpConfigured; + + const onRemoveConfirm = useCallback(() => { + setIsConfirmOpen(false); + return method.type === 'totp' + ? openRemoveTotpPopup() + : history.push(`/settings/recovery-method/fido/${method.id}`); + }, [history, method]); + + return ( + <> + {isConfirmOpen && ( + setIsConfirmOpen(false)} + warning={t( + 'You will no longer be able to use this method once you removed.', + )} + /> + )} + {isChangeOpen && ( + setIsChangeOpen(false)} + warning={t( + 'You will no longer be able to use this authenticator once you switch. You will need to re-add an authenticator', + )} + /> + )} + + + {method.type === 'totp' && ( + + )} + {method.type === 'fido' && } + + + + {method.type === 'totp' && ( + + )} + + + + ); +}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/RecoveryMethodCard.tsx b/apps/next/src/pages/Settings/components/RecoveryMethods/RecoveryMethodCard.tsx new file mode 100644 index 000000000..3e3aa0a02 --- /dev/null +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/RecoveryMethodCard.tsx @@ -0,0 +1,35 @@ +import { CardMenuItem } from '@/pages/Onboarding/components/CardMenu'; +import { MethodIcons } from './RecoveryMethodList'; +import { RecoveryMethod } from '@core/types'; + +interface RecoveryMethodCardProps { + method: RecoveryMethod; + onClick: () => void; + methodName?: string; +} + +export const getIconForMethod = (method) => { + if (method.type === 'totp') { + return MethodIcons.authenticator; + } + if (method.aaguid === '00000000-0000-0000-0000-000000000000') { + return MethodIcons.yubikey; + } + return MethodIcons.passkey; +}; + +export const RecoveryMethodCard = ({ + method, + onClick, + methodName, +}: RecoveryMethodCardProps) => { + return ( + + ); +}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/RecoveryMethodList.tsx b/apps/next/src/pages/Settings/components/RecoveryMethods/RecoveryMethodList.tsx new file mode 100644 index 000000000..ed3cdf126 --- /dev/null +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/RecoveryMethodList.tsx @@ -0,0 +1,145 @@ +import { WarningMessage } from '@/components/WarningMessage'; +import { CardMenu, CardMenuItem } from '@/pages/Onboarding/components/CardMenu'; +import { + Button, + Divider, + EncryptedIcon, + Paper, + PasswordIcon, + SecurityKeyIcon, +} from '@avalabs/k2-alpine'; +import { openFullscreenTab } from '@core/common'; +import { FeatureGates } from '@core/types'; +import { useAnalyticsContext, useFeatureFlagContext } from '@core/ui'; +import { useTranslation } from 'react-i18next'; +import { useHistory } from 'react-router-dom'; + +export const MethodIcons = { + passkey: , + authenticator: , + yubikey: , +}; + +export const RecoveryMethodList = ({ + hasTotpConfigured, + hasMFAConfigured, + onNext, +}: { + hasTotpConfigured: boolean; + hasMFAConfigured: boolean; + onNext: () => void; +}) => { + const { t } = useTranslation(); + const history = useHistory(); + const { capture } = useAnalyticsContext(); + const { featureFlags } = useFeatureFlagContext(); + const isPasskeyOn = featureFlags[FeatureGates.SEEDLESS_MFA_PASSKEY]; + const isYubikeyOn = featureFlags[FeatureGates.SEEDLESS_MFA_YUBIKEY]; + const isAuthenticatorOn = + featureFlags[FeatureGates.SEEDLESS_MFA_AUTHENTICATOR]; + + const noMFAMethodsAvailable = + !isPasskeyOn && !isYubikeyOn && (!isAuthenticatorOn || hasTotpConfigured); + + const recoveryMethodCards = [ + { + icon: MethodIcons.passkey, + title: t('Passkey'), + description: t( + 'Passkeys are used for quick, password-free recovery and enhanced security.', + ), + to: 'update-recovery-method/fido/add/passkey', + analyticsKey: 'AddPasskeyClicked', + method: 'passkey', + isOn: isPasskeyOn, + }, + { + icon: MethodIcons.authenticator, + title: t('Authenticator app'), + description: t( + 'Authenticator apps generate secure, time-based codes for wallet recovery.', + ), + // add url for in-extension version + to: !hasMFAConfigured + ? '/settings/recovery-method/authenticator' + : 'update-recovery-method/totp/add', + analyticsKey: 'AddAuthenticatorClicked', + method: 'authenticator', + newTab: !hasMFAConfigured ? false : true, + isOn: isAuthenticatorOn, + }, + { + icon: MethodIcons.yubikey, + title: t('Yubikey'), + description: t( + 'YubiKeys are physical, hardware-based protection and strong authentication.', + ), + to: 'update-recovery-method/fido/add/yubikey', + analyticsKey: 'AddYubikeyClicked', + method: 'yubikey', + isOn: isYubikeyOn, + }, + ]; + + return ( + <> + + {!noMFAMethodsAvailable && ( + }> + {recoveryMethodCards.map((card, idx) => { + if ( + (card.method === 'authenticator' && hasTotpConfigured) || + !card.isOn + ) { + return null; + } + + return ( + { + capture(card.analyticsKey); + if (card.newTab === false) { + history.push(card.to); + return; + } + openFullscreenTab(card.to); + }} + icon={card.icon} + text={card.title} + description={card.description} + key={idx} + size="small" + /> + ); + })} + + )} + {noMFAMethodsAvailable && ( + + {t( + 'You cannot add a new recovery method for your wallet! Try again later!', + )} + + )} + + + + ); +}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/RecoveryMethods.tsx b/apps/next/src/pages/Settings/components/RecoveryMethods/RecoveryMethods.tsx new file mode 100644 index 000000000..7f4007f44 --- /dev/null +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/RecoveryMethods.tsx @@ -0,0 +1,115 @@ +import { Page } from '@/components/Page'; +import { Button, Paper, Skeleton } from '@avalabs/k2-alpine'; +import { RecoveryMethod as RecoveryMethodType } from '@core/types'; +import { useAnalyticsContext, useSeedlessMfaManager } from '@core/ui'; +import { FC, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { RecoveryMethodList } from './RecoveryMethodList'; +import { RecoveryMethod } from './RecoveryMethod'; +import { ConfiguredMethodList } from './ConfiguredMethodList'; +import { useHistory } from 'react-router-dom'; + +export enum RecoveryMethodScreen { + ConfiguredList = 'configured-list', + NewList = 'new-list', + Method = 'method', +} + +export const RecoveryMethods: FC = () => { + const { t } = useTranslation(); + const { capture } = useAnalyticsContext(); + const history = useHistory(); + const [selectedMethod, setSelectedMethod] = + useState(null); + const { + isLoadingRecoveryMethods, + recoveryMethods: existingRecoveryMethods, + hasMfaConfigured, + hasTotpConfigured, + } = useSeedlessMfaManager(); + + const [screen, setScreen] = useState(); + + useEffect(() => { + if (isLoadingRecoveryMethods) { + return; + } + if (!hasMfaConfigured) { + setScreen(RecoveryMethodScreen.NewList); + } + if (hasMfaConfigured) { + setScreen(RecoveryMethodScreen.ConfiguredList); + } + }, [hasMfaConfigured, isLoadingRecoveryMethods]); + + return ( + { + if (screen !== RecoveryMethodScreen.Method) { + history.push('/settings'); + return; + } + if (hasMfaConfigured) { + setScreen(RecoveryMethodScreen.ConfiguredList); + return; + } + setScreen(RecoveryMethodScreen.NewList); + }} + > + {isLoadingRecoveryMethods && ( + + + + )} + + {!isLoadingRecoveryMethods && screen === RecoveryMethodScreen.NewList && ( + { + capture('AddRecoveryMethodClicked'); + setScreen(RecoveryMethodScreen.NewList); + }} + /> + )} + + {!isLoadingRecoveryMethods && + screen === RecoveryMethodScreen.ConfiguredList && ( + + )} + + {!isLoadingRecoveryMethods && + screen === RecoveryMethodScreen.Method && + selectedMethod && } + {screen === RecoveryMethodScreen.ConfiguredList && ( + + )} + + ); +}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/components/ConfirmPage.tsx b/apps/next/src/pages/Settings/components/RecoveryMethods/components/ConfirmPage.tsx new file mode 100644 index 000000000..e4ab29475 --- /dev/null +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/components/ConfirmPage.tsx @@ -0,0 +1,42 @@ +import { SlideUpDialog } from '@/components/Dialog'; +import { Page } from '@/components/Page'; +import { Button, Stack } from '@avalabs/k2-alpine'; +import { useTranslation } from 'react-i18next'; +import { RecoveryMethodFailure } from './RecoveryMethodFailure'; + +export const ConfirmPage = ({ onConfirm, onCancel, title, warning }) => { + const { t } = useTranslation(); + return ( + + + + + + + + + + ); +}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/components/RecoveryMethodFailure.tsx b/apps/next/src/pages/Settings/components/RecoveryMethods/components/RecoveryMethodFailure.tsx new file mode 100644 index 000000000..2c6fc938b --- /dev/null +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/components/RecoveryMethodFailure.tsx @@ -0,0 +1,19 @@ +import { WarningMessage } from '@/components/WarningMessage'; +import { Stack } from '@avalabs/k2-alpine'; +import { useTranslation } from 'react-i18next'; + +export const RecoveryMethodFailure = ({ text }: { text?: string }) => { + const { t } = useTranslation(); + return ( + + + {text || t('Error occurred. Please try again.')} + + + ); +}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/components/RecoveryMethodsList.tsx b/apps/next/src/pages/Settings/components/RecoveryMethods/components/RecoveryMethodsList.tsx deleted file mode 100644 index 511599c6c..000000000 --- a/apps/next/src/pages/Settings/components/RecoveryMethods/components/RecoveryMethodsList.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Divider, EncryptedIcon } from '@avalabs/k2-alpine'; -import { FC } from 'react'; -import { useTranslation } from 'react-i18next'; -import { MdKey, MdOutlinePassword } from 'react-icons/md'; - -import { Page } from '@/components/Page/Page'; -import { CardMenu, CardMenuItem } from '@/pages/Onboarding/components/CardMenu'; - -/** TODO: It came out that I don't need this component for seedless recovery, but I'm keeping it because it can be used later */ -export const RecoveryMethodsList: FC = () => { - const { t } = useTranslation(); - - return ( - - }> - { - // TODO: Navigate to passkey setup - }} - icon={} - text={t('Passkey')} - description={t( - 'Passkeys are used for quick, password-free recovery and enhanced security.', - )} - /> - { - // TODO: Navigate to authenticator app setup - }} - icon={} - text={t('Authenticator app')} - description={t( - 'Authenticator apps generate secure, time-based codes for wallet recovery.', - )} - /> - { - // TODO: Navigate to yubikey setup - }} - icon={} - text={t('Yubikey')} - description={t( - 'YubiKeys are physical, hardware-based protection and strong authentication.', - )} - /> - - - ); -}; diff --git a/apps/next/src/pages/Settings/components/RecoveryMethods/index.ts b/apps/next/src/pages/Settings/components/RecoveryMethods/index.ts index 534a15aea..1251a3f5b 100644 --- a/apps/next/src/pages/Settings/components/RecoveryMethods/index.ts +++ b/apps/next/src/pages/Settings/components/RecoveryMethods/index.ts @@ -1 +1 @@ -export { RecoveryMethodsList } from './components/RecoveryMethodsList'; +export * from './RecoveryMethods'; diff --git a/apps/next/src/pages/Settings/components/RecoveryPhrase/components/ShowPhrase/components/SeedlessFlow/pages/MFA/MFA.tsx b/apps/next/src/pages/Settings/components/RecoveryPhrase/components/ShowPhrase/components/SeedlessFlow/pages/MFA/MFA.tsx index ae9add3c7..d1d461236 100644 --- a/apps/next/src/pages/Settings/components/RecoveryPhrase/components/ShowPhrase/components/SeedlessFlow/pages/MFA/MFA.tsx +++ b/apps/next/src/pages/Settings/components/RecoveryPhrase/components/ShowPhrase/components/SeedlessFlow/pages/MFA/MFA.tsx @@ -3,15 +3,14 @@ import { AuthErrorCode, MfaRequestType } from '@core/types'; import { FC, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { InProgress } from '../../../InProgress'; -import { StageProps } from '../../types'; -import { FIDOChallenge } from './components/FIDOChallenge'; +import { FIDOChallenge } from '../../../../../../../common/FIDOChallenge'; import { MfaChoicePrompt } from './components/MfaChoicePrompt'; import { TOTPChallenge } from './components/TOTPChallenge'; import { useMFAChoice } from './hooks/useMFAChoice'; -import { useMFAEvents } from './hooks/useMFAEvent'; +import { useMFAEvents } from '../../../../../../../common/useMFAEvent'; import { useSelectMFAMethod } from './hooks/useSelectMFAMethod'; -export const MFA: FC = () => { +export const MFA: FC = () => { const [error, setError] = useState(); const mfaChoice = useMFAChoice(); const mfaChallenge = useMFAEvents(setError); @@ -45,7 +44,7 @@ export const MFA: FC = () => { /> )} - {!mfaChoice.choice && ( + {!mfaChoice.choice && !mfaChallenge && ( {t('Fetching available authentication methods...')} diff --git a/apps/next/src/pages/Settings/components/RecoveryPhrase/components/ShowPhrase/components/SeedlessFlow/pages/MFA/components/TOTPChallenge.tsx b/apps/next/src/pages/Settings/components/RecoveryPhrase/components/ShowPhrase/components/SeedlessFlow/pages/MFA/components/TOTPChallenge.tsx index 039bcf5cc..c75b3b114 100644 --- a/apps/next/src/pages/Settings/components/RecoveryPhrase/components/ShowPhrase/components/SeedlessFlow/pages/MFA/components/TOTPChallenge.tsx +++ b/apps/next/src/pages/Settings/components/RecoveryPhrase/components/ShowPhrase/components/SeedlessFlow/pages/MFA/components/TOTPChallenge.tsx @@ -78,7 +78,7 @@ export const TOTPChallenge: FC = ({ error, challenge, onError }) => {