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 }) => {