From 0712608acdc8e3cd91936f4ecb05477909351e84 Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Thu, 19 Feb 2026 17:31:02 +0500 Subject: [PATCH 01/14] refactor: rename email login types to generic OTP for SMS support --- packages/@magic-ext/wallet-kit/src/reducer.ts | 93 ++++++++++--------- packages/@magic-ext/wallet-kit/src/types.ts | 17 +++- 2 files changed, 64 insertions(+), 46 deletions(-) diff --git a/packages/@magic-ext/wallet-kit/src/reducer.ts b/packages/@magic-ext/wallet-kit/src/reducer.ts index 274b23783..c3763b331 100644 --- a/packages/@magic-ext/wallet-kit/src/reducer.ts +++ b/packages/@magic-ext/wallet-kit/src/reducer.ts @@ -9,7 +9,7 @@ export type View = | 'wallet_pending' | 'walletconnect_pending' | 'oauth_pending' - | 'email_otp_pending' + | 'otp_pending' | 'device_verification' | 'mfa_pending' | 'recovery_code' @@ -19,7 +19,7 @@ export type View = | 'farcaster_success' | 'farcaster_failed'; -export type EmailLoginStatus = +export type OtpLoginStatus = | 'idle' | 'sending' | 'otp_sent' @@ -42,13 +42,15 @@ export type EmailLoginStatus = export interface WidgetState { view: View; - // Data passed between views - email?: string; + // Data passed between views (email or phone number) + identifier?: string; selectedProvider?: LoginProvider; walletAddress?: string; // For WalletConnect when using EthereumProvider directly error?: string; - // Email login flow state - emailLoginStatus?: EmailLoginStatus; + // OTP login flow state (email or SMS) + otpLoginStatus?: OtpLoginStatus; + // Login method: 'email' or 'sms' + loginMethod?: 'email' | 'sms'; // Farcaster flow state farcasterUrl?: string; farcasterUsername?: string; @@ -57,13 +59,13 @@ export interface WidgetState { export type WidgetAction = // Navigation actions | { type: 'GO_TO_LOGIN' } - // Email flow - | { type: 'EMAIL_OTP_START'; email: string } - | { type: 'EMAIL_OTP_SENT' } - | { type: 'EMAIL_OTP_INVALID' } - | { type: 'EMAIL_OTP_EXPIRED' } - | { type: 'EMAIL_OTP_MAX_ATTEMPTS_REACHED' } - | { type: 'EMAIL_OTP_VERIFYING' } + // OTP flow (email or SMS) + | { type: 'OTP_START'; identifier: string; loginMethod?: 'email' | 'sms' } + | { type: 'OTP_SENT' } + | { type: 'OTP_INVALID' } + | { type: 'OTP_EXPIRED' } + | { type: 'OTP_MAX_ATTEMPTS_REACHED' } + | { type: 'OTP_VERIFYING' } | { type: 'DEVICE_NEEDS_APPROVAL' } | { type: 'DEVICE_VERIFICATION_SENT' } | { type: 'DEVICE_VERIFICATION_EXPIRED' } @@ -76,7 +78,7 @@ export type WidgetAction = | { type: 'RECOVERY_CODE_INVALID' } | { type: 'LOST_RECOVERY_CODE' } | { type: 'LOGIN_SUCCESS' } - | { type: 'RESET_EMAIL_ERROR' } + | { type: 'RESET_OTP_ERROR' } | { type: 'LOGIN_ERROR'; error: string } // OAuth flow | { type: 'SELECT_PROVIDER'; provider: OAuthProvider } @@ -92,7 +94,7 @@ export type WidgetAction = export const initialState: WidgetState = { view: 'login', - emailLoginStatus: 'idle', + otpLoginStatus: 'idle', }; export function widgetReducer(state: WidgetState, action: WidgetAction): WidgetState { @@ -103,52 +105,53 @@ export function widgetReducer(state: WidgetState, action: WidgetAction): WidgetS ...initialState, selectedProvider: undefined, walletAddress: undefined, - email: undefined, + identifier: undefined, error: undefined, }; - // Email OTP flow - case 'EMAIL_OTP_START': + // OTP flow (email or SMS) + case 'OTP_START': return { ...state, - email: action.email, - emailLoginStatus: 'sending', + identifier: action.identifier, + loginMethod: action.loginMethod || 'email', + otpLoginStatus: 'sending', error: undefined, }; - case 'EMAIL_OTP_SENT': + case 'OTP_SENT': return { ...state, - view: 'email_otp_pending', - emailLoginStatus: 'otp_sent', + view: 'otp_pending', + otpLoginStatus: 'otp_sent', error: undefined, }; - case 'EMAIL_OTP_VERIFYING': + case 'OTP_VERIFYING': return { ...state, - emailLoginStatus: 'verifying_otp', + otpLoginStatus: 'verifying_otp', error: undefined, }; - case 'EMAIL_OTP_INVALID': + case 'OTP_INVALID': return { ...state, - emailLoginStatus: 'invalid_otp', + otpLoginStatus: 'invalid_otp', error: 'Invalid code. Please try again.', }; - case 'EMAIL_OTP_EXPIRED': + case 'OTP_EXPIRED': return { ...state, - emailLoginStatus: 'expired_otp', + otpLoginStatus: 'expired_otp', error: 'Code expired. Please request a new one.', }; - case 'EMAIL_OTP_MAX_ATTEMPTS_REACHED': + case 'OTP_MAX_ATTEMPTS_REACHED': return { ...state, - emailLoginStatus: 'max_attempts_reached', + otpLoginStatus: 'max_attempts_reached', error: 'Max attempts reached. Please request a new code.', }; @@ -156,7 +159,7 @@ export function widgetReducer(state: WidgetState, action: WidgetAction): WidgetS return { ...state, view: 'device_verification', - emailLoginStatus: 'device_needs_approval', + otpLoginStatus: 'device_needs_approval', error: undefined, }; @@ -164,21 +167,21 @@ export function widgetReducer(state: WidgetState, action: WidgetAction): WidgetS return { ...state, view: 'device_verification', - emailLoginStatus: 'device_verification_sent', + otpLoginStatus: 'device_verification_sent', error: undefined, }; case 'DEVICE_VERIFICATION_EXPIRED': return { ...state, - emailLoginStatus: 'device_verification_expired', + otpLoginStatus: 'device_verification_expired', error: 'Verification link expired. Please try again.', }; case 'DEVICE_APPROVED': return { ...state, - emailLoginStatus: 'device_approved', + otpLoginStatus: 'device_approved', error: undefined, }; @@ -186,21 +189,21 @@ export function widgetReducer(state: WidgetState, action: WidgetAction): WidgetS return { ...state, view: 'mfa_pending', - emailLoginStatus: 'mfa_required', + otpLoginStatus: 'mfa_required', error: undefined, }; case 'MFA_VERIFYING': return { ...state, - emailLoginStatus: 'mfa_verifying', + otpLoginStatus: 'mfa_verifying', error: undefined, }; case 'MFA_INVALID': return { ...state, - emailLoginStatus: 'mfa_invalid', + otpLoginStatus: 'mfa_invalid', error: 'Invalid code. Please try again.', }; @@ -208,21 +211,21 @@ export function widgetReducer(state: WidgetState, action: WidgetAction): WidgetS return { ...state, view: 'recovery_code', - emailLoginStatus: 'recovery_code', + otpLoginStatus: 'recovery_code', error: undefined, }; case 'RECOVERY_CODE_VERIFYING': return { ...state, - emailLoginStatus: 'recovery_code_verifying', + otpLoginStatus: 'recovery_code_verifying', error: undefined, }; case 'RECOVERY_CODE_INVALID': return { ...state, - emailLoginStatus: 'recovery_code', + otpLoginStatus: 'recovery_code', error: 'Invalid recovery code. Please try again.', }; @@ -230,7 +233,7 @@ export function widgetReducer(state: WidgetState, action: WidgetAction): WidgetS return { ...state, view: 'lost_recovery_code', - emailLoginStatus: 'lost_recovery_code', + otpLoginStatus: 'lost_recovery_code', error: undefined, }; @@ -238,18 +241,18 @@ export function widgetReducer(state: WidgetState, action: WidgetAction): WidgetS return { ...state, view: 'login_success', - emailLoginStatus: 'success', + otpLoginStatus: 'success', error: undefined, }; case 'LOGIN_ERROR': return { ...state, - emailLoginStatus: 'error', + otpLoginStatus: 'error', error: action.error, }; - case 'RESET_EMAIL_ERROR': + case 'RESET_OTP_ERROR': return { ...state, error: undefined, diff --git a/packages/@magic-ext/wallet-kit/src/types.ts b/packages/@magic-ext/wallet-kit/src/types.ts index a9336a458..b6ed4746a 100644 --- a/packages/@magic-ext/wallet-kit/src/types.ts +++ b/packages/@magic-ext/wallet-kit/src/types.ts @@ -24,6 +24,7 @@ export interface ProviderMetadata { export enum RpcErrorMessage { MalformedEmail = 'Invalid params: Please provide a valid email address.', SanEmail = 'We are unable to create an account with that email.', + InvalidPhoneNumber = 'Invalid params: Invalid phone number.', } export enum OAuthProvider { @@ -70,6 +71,15 @@ export interface EmailLoginResult { didToken: string; } +/** + * Result returned on successful SMS login + */ +export interface SmsLoginResult { + method: 'sms'; + /** The DID token for authentication */ + didToken: string; +} + /** * Result returned on successful OAuth login */ @@ -120,7 +130,12 @@ export interface FarcasterLoginResult { * } * }} */ -export type LoginResult = EmailLoginResult | OAuthLoginResult | WalletLoginResult | FarcasterLoginResult; +export type LoginResult = + | EmailLoginResult + | SmsLoginResult + | OAuthLoginResult + | WalletLoginResult + | FarcasterLoginResult; /** * How the widget is displayed on the page. From 7f8bc691749035ca4187366159ca078681090753 Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Thu, 19 Feb 2026 17:31:48 +0500 Subject: [PATCH 02/14] feat: add MFA and recovery code events for SMS login --- .../@magic-sdk/provider/src/modules/auth.ts | 15 +++++++++++++++ .../@magic-sdk/types/src/modules/auth-types.ts | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/packages/@magic-sdk/provider/src/modules/auth.ts b/packages/@magic-sdk/provider/src/modules/auth.ts index c5e7b7fba..e04e3b60f 100644 --- a/packages/@magic-sdk/provider/src/modules/auth.ts +++ b/packages/@magic-sdk/provider/src/modules/auth.ts @@ -82,6 +82,21 @@ export class AuthModule extends BaseModule { this.createIntermediaryEvent(LoginWithSmsOTPEventEmit.VerifySmsOtp, requestPayload.id as string)(otp); }); + handle.on(LoginWithSmsOTPEventEmit.VerifyMFACode, (mfa: string) => { + this.createIntermediaryEvent(LoginWithSmsOTPEventEmit.VerifyMFACode, requestPayload.id as string)(mfa); + }); + + handle.on(LoginWithSmsOTPEventEmit.LostDevice, () => { + this.createIntermediaryEvent(LoginWithSmsOTPEventEmit.LostDevice, requestPayload.id as string)(); + }); + + handle.on(LoginWithSmsOTPEventEmit.VerifyRecoveryCode, (recoveryCode: string) => { + this.createIntermediaryEvent( + LoginWithSmsOTPEventEmit.VerifyRecoveryCode, + requestPayload.id as string, + )(recoveryCode); + }); + handle.on(LoginWithSmsOTPEventEmit.Cancel, () => { this.createIntermediaryEvent(LoginWithSmsOTPEventEmit.Cancel, requestPayload.id as string)(); }); diff --git a/packages/@magic-sdk/types/src/modules/auth-types.ts b/packages/@magic-sdk/types/src/modules/auth-types.ts index d637a807e..de72fc294 100644 --- a/packages/@magic-sdk/types/src/modules/auth-types.ts +++ b/packages/@magic-sdk/types/src/modules/auth-types.ts @@ -159,6 +159,9 @@ export enum LoginWithEmailOTPEventEmit { export enum LoginWithSmsOTPEventEmit { VerifySmsOtp = 'verify-sms-otp', + VerifyMFACode = 'verify-mfa-code', + LostDevice = 'lost-device', + VerifyRecoveryCode = 'verify-recovery-code', Cancel = 'cancel', Retry = 'retry', } @@ -167,6 +170,12 @@ export enum LoginWithSmsOTPEventOnReceived { SmsOTPSent = 'sms-otp-sent', InvalidSmsOtp = 'invalid-sms-otp', ExpiredSmsOtp = 'expired-sms-otp', + MfaSentHandle = 'mfa-sent-handle', + InvalidMfaOtp = 'invalid-mfa-otp', + LoginThrottled = 'login-throttled', + RecoveryCodeSentHandle = 'recovery-code-sent-handle', + InvalidRecoveryCode = 'invalid-recovery-code', + RecoveryCodeSuccess = 'recovery-code-success', } export enum LoginWithEmailOTPEventOnReceived { @@ -271,6 +280,9 @@ export type LoginWithMagicLinkEventHandlers = { export type LoginWithSmsOTPEventHandlers = { // Event sent [LoginWithSmsOTPEventEmit.VerifySmsOtp]: (otp: string) => void; + [LoginWithSmsOTPEventEmit.VerifyMFACode]: (mfa: string) => void; + [LoginWithSmsOTPEventEmit.LostDevice]: () => void; + [LoginWithSmsOTPEventEmit.VerifyRecoveryCode]: (recoveryCode: string) => void; [LoginWithSmsOTPEventEmit.Cancel]: () => void; [LoginWithSmsOTPEventEmit.Retry]: () => void; @@ -278,6 +290,12 @@ export type LoginWithSmsOTPEventHandlers = { [LoginWithSmsOTPEventOnReceived.SmsOTPSent]: () => void; [LoginWithSmsOTPEventOnReceived.InvalidSmsOtp]: () => void; [LoginWithSmsOTPEventOnReceived.ExpiredSmsOtp]: () => void; + [LoginWithSmsOTPEventOnReceived.MfaSentHandle]: () => void; + [LoginWithSmsOTPEventOnReceived.InvalidMfaOtp]: () => void; + [LoginWithSmsOTPEventOnReceived.LoginThrottled]: () => void; + [LoginWithSmsOTPEventOnReceived.RecoveryCodeSentHandle]: () => void; + [LoginWithSmsOTPEventOnReceived.InvalidRecoveryCode]: () => void; + [LoginWithSmsOTPEventOnReceived.RecoveryCodeSuccess]: () => void; } & DeviceVerificationEventHandlers; export type LoginWithEmailOTPEventHandlers = { From 4f42136ce59fad37ed069b6e2921f65c6d21dcd3 Mon Sep 17 00:00:00 2001 From: sherzod-bakhodirov Date: Thu, 19 Feb 2026 17:33:08 +0500 Subject: [PATCH 03/14] feat: implement SMS login support in wallet kit --- .../wallet-kit/src/components/SmsInput.tsx | 86 ++++++ .../src/context/SmsLoginContext.tsx | 267 ++++++++++++++++++ .../@magic-ext/wallet-kit/src/extension.ts | 7 + .../views/{EmailOTPView.tsx => OtpView.tsx} | 43 +-- 4 files changed, 387 insertions(+), 16 deletions(-) create mode 100644 packages/@magic-ext/wallet-kit/src/components/SmsInput.tsx create mode 100644 packages/@magic-ext/wallet-kit/src/context/SmsLoginContext.tsx rename packages/@magic-ext/wallet-kit/src/views/{EmailOTPView.tsx => OtpView.tsx} (55%) diff --git a/packages/@magic-ext/wallet-kit/src/components/SmsInput.tsx b/packages/@magic-ext/wallet-kit/src/components/SmsInput.tsx new file mode 100644 index 000000000..5be454010 --- /dev/null +++ b/packages/@magic-ext/wallet-kit/src/components/SmsInput.tsx @@ -0,0 +1,86 @@ +import React, { FormEvent, useState } from 'react'; +import { Button, Text, IconSms, IcoArrowRight, PhoneInput } from '@magiclabs/ui-components'; +import { RpcErrorMessage } from 'src/types'; +import { useSmsLogin } from '../context/SmsLoginContext'; +import { vstack } from '@styled/patterns'; +import { Box, Flex } from '@styled/jsx'; +import { token } from '@styled/tokens'; +import { getExtensionInstance } from 'src/extension'; +import { isValidPhoneNumber } from 'libphonenumber-js'; + +interface SmsInputProps { + error?: string; + isLoading?: boolean; +} + +export const SmsInput = ({ error: externalError, isLoading }: SmsInputProps) => { + const { startSmsLogin } = useSmsLogin(); + const [phoneNumber, setPhoneNumber] = useState(''); + const [localError, setLocalError] = useState(null); + const [disabled, setDisabled] = useState(true); + const config = getExtensionInstance().getConfig(); + + const isPhoneValid = phoneNumber.length > 0 && isValidPhoneNumber(phoneNumber); + + // Use external error if available, otherwise fall back to local error + const displayError = externalError || localError; + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + + if (!isValidPhoneNumber(phoneNumber)) { + setLocalError(RpcErrorMessage.InvalidPhoneNumber); + return; + } + + setDisabled(true); + startSmsLogin(phoneNumber); + }; + + const handleInput = (value: string) => { + setLocalError(null); + setDisabled(!value.length); + setPhoneNumber(value.trim()); + }; + + const handlePhoneChange = (phone: string) => { + handleInput(phone); + }; + + return ( +
+ + + + + + + + {displayError && ( + + {displayError} + + )} +
+ ); +}; diff --git a/packages/@magic-ext/wallet-kit/src/context/SmsLoginContext.tsx b/packages/@magic-ext/wallet-kit/src/context/SmsLoginContext.tsx new file mode 100644 index 000000000..97996989b --- /dev/null +++ b/packages/@magic-ext/wallet-kit/src/context/SmsLoginContext.tsx @@ -0,0 +1,267 @@ +import React, { createContext, useContext, useRef, useCallback, useState, ReactNode } from 'react'; +import { getExtensionInstance } from '../extension'; +import { WidgetAction } from '../reducer'; +import { + DeviceVerificationEventEmit, + DeviceVerificationEventOnReceived, + LoginWithSmsOTPEventEmit, + LoginWithSmsOTPEventOnReceived, + LoginWithEmailOTPEventOnReceived, +} from '@magic-sdk/types'; +import { useWidgetConfig } from './WidgetConfigContext'; + +type SmsOTPHandle = ReturnType['loginWithSMS']>; + +interface SmsLoginContextValue { + startSmsLogin: (phoneNumber: string) => void; + submitOTP: (otp: string) => void; + submitMFA: (totp: string) => void; + lostDevice: () => void; + submitRecoveryCode: (recoveryCode: string) => void; + cancelLogin: () => void; + retryDeviceVerification: () => void; + resendSmsOTP: () => void; + phoneNumber: string | null; + isSmsLoginActive: boolean; +} + +const SmsLoginContext = createContext(null); + +interface SmsLoginProviderProps { + children: ReactNode; + dispatch: React.Dispatch; +} + +export function SmsLoginProvider({ children, dispatch }: SmsLoginProviderProps) { + const { handleSuccess, handleError } = useWidgetConfig(); + + const handleRef = useRef(null); + const phoneNumberRef = useRef(null); + const [isSmsLoginActive, setIsSmsLoginActive] = useState(false); + + /** + * Start the SMS OTP login flow + * Sets up all event listeners and manages state transitions + */ + const startSmsLogin = useCallback( + (phoneNumber: string) => { + phoneNumberRef.current = phoneNumber; + setIsSmsLoginActive(true); + dispatch({ type: 'OTP_START', identifier: phoneNumber, loginMethod: 'sms' }); + + try { + const extension = getExtensionInstance(); + const handle = extension.loginWithSMS(phoneNumber); + handleRef.current = handle; + + // ========================================== + // SMS OTP Events + // ========================================== + + // OTP was sent successfully + handle.on(LoginWithSmsOTPEventOnReceived.SmsOTPSent, () => { + dispatch({ type: 'OTP_SENT' }); + }); + + // Invalid OTP entered + handle.on(LoginWithSmsOTPEventOnReceived.InvalidSmsOtp, () => { + dispatch({ type: 'OTP_INVALID' }); + }); + + // OTP has expired + handle.on(LoginWithSmsOTPEventOnReceived.ExpiredSmsOtp, () => { + dispatch({ type: 'OTP_EXPIRED' }); + }); + + // Login throttled (too many attempts) + handle.on(LoginWithSmsOTPEventOnReceived.LoginThrottled, () => { + dispatch({ type: 'LOGIN_ERROR', error: 'Too many login attempts. Please try again later.' }); + }); + + // ========================================== + // Device Verification Events + // ========================================== + + // Device needs approval (unrecognized device) + handle.on(DeviceVerificationEventOnReceived.DeviceNeedsApproval, () => { + dispatch({ type: 'DEVICE_NEEDS_APPROVAL' }); + }); + + // Device verification email sent + handle.on(DeviceVerificationEventOnReceived.DeviceVerificationEmailSent, () => { + dispatch({ type: 'DEVICE_VERIFICATION_SENT' }); + }); + + // Device verification link expired + handle.on(DeviceVerificationEventOnReceived.DeviceVerificationLinkExpired, () => { + dispatch({ type: 'DEVICE_VERIFICATION_EXPIRED' }); + }); + + // Device approved successfully + handle.on(DeviceVerificationEventOnReceived.DeviceApproved, () => { + dispatch({ type: 'DEVICE_APPROVED' }); + }); + + // ========================================== + // MFA Events (if enabled) + // Note: SMS login uses email OTP events for MFA + // ========================================== + + handle.on(LoginWithEmailOTPEventOnReceived.MfaSentHandle, () => { + dispatch({ type: 'MFA_REQUIRED' }); + }); + + handle.on(LoginWithEmailOTPEventOnReceived.InvalidMfaOtp, () => { + dispatch({ type: 'MFA_INVALID' }); + }); + + // ========================================== + // Recovery Code Events + // ========================================== + + handle.on(LoginWithSmsOTPEventOnReceived.InvalidRecoveryCode, () => { + dispatch({ type: 'RECOVERY_CODE_INVALID' }); + }); + + // ========================================== + // Handle Promise Resolution + // ========================================== + + handle + .then(didToken => { + if (didToken) { + dispatch({ type: 'LOGIN_SUCCESS' }); + handleSuccess({ method: 'sms', didToken }); + } + }) + .catch(error => { + const errorInstance = error instanceof Error ? error : new Error(error?.message || 'Login failed'); + dispatch({ type: 'LOGIN_ERROR', error: errorInstance.message }); + handleError(errorInstance); + }); + } catch (error) { + const errorInstance = error instanceof Error ? error : new Error('Failed to start login'); + dispatch({ + type: 'LOGIN_ERROR', + error: errorInstance.message, + }); + handleError(errorInstance); + } + }, + [dispatch, handleSuccess, handleError], + ); + + /** + * Submit OTP code for verification + */ + const submitOTP = useCallback( + (otp: string) => { + if (handleRef.current) { + dispatch({ type: 'OTP_VERIFYING' }); + handleRef.current.emit(LoginWithSmsOTPEventEmit.VerifySmsOtp, otp); + } + }, + [dispatch], + ); + + /** + * Submit MFA code for verification + */ + const submitMFA = useCallback( + (totp: string) => { + if (handleRef.current) { + dispatch({ type: 'MFA_VERIFYING' }); + handleRef.current.emit(LoginWithSmsOTPEventEmit.VerifyMFACode, totp); + } + }, + [dispatch], + ); + + /** + * Lost device + */ + const lostDevice = useCallback(() => { + if (handleRef.current) { + dispatch({ type: 'LOST_DEVICE' }); + handleRef.current.emit(LoginWithSmsOTPEventEmit.LostDevice); + } + }, [dispatch]); + + /** + * Submit recovery code for verification + */ + const submitRecoveryCode = useCallback( + (recoveryCode: string) => { + if (handleRef.current) { + dispatch({ type: 'RECOVERY_CODE_VERIFYING' }); + handleRef.current.emit(LoginWithSmsOTPEventEmit.VerifyRecoveryCode, recoveryCode); + } + }, + [dispatch], + ); + + /** + * Cancel the current login flow + */ + const cancelLogin = useCallback(() => { + if (handleRef.current) { + handleRef.current.emit(LoginWithSmsOTPEventEmit.Cancel); + } + handleRef.current = null; + phoneNumberRef.current = null; + setIsSmsLoginActive(false); + dispatch({ type: 'GO_TO_LOGIN' }); + }, [dispatch]); + + /** + * Retry device verification + */ + const retryDeviceVerification = useCallback(() => { + if (handleRef.current) { + handleRef.current.emit(DeviceVerificationEventEmit.Retry); + } + }, []); + + const resendSmsOTP = useCallback(() => { + const phoneNumber = phoneNumberRef.current; + + if (handleRef.current) { + handleRef.current.emit(LoginWithSmsOTPEventEmit.Cancel); + } + handleRef.current = null; + phoneNumberRef.current = null; + + if (!phoneNumber) { + return dispatch({ type: 'LOGIN_ERROR', error: 'Internal error: No phone number found' }); + } + + startSmsLogin(phoneNumber); + }, [startSmsLogin]); + + const value: SmsLoginContextValue = { + startSmsLogin, + submitOTP, + submitMFA, + lostDevice, + submitRecoveryCode, + cancelLogin, + retryDeviceVerification, + resendSmsOTP, + phoneNumber: phoneNumberRef.current, + isSmsLoginActive, + }; + + return {children}; +} + +/** + * Hook to access the SMS login context + * @throws Error if used outside of SmsLoginProvider + */ +export function useSmsLogin(): SmsLoginContextValue { + const context = useContext(SmsLoginContext); + if (!context) { + throw new Error('useSmsLogin must be used within a SmsLoginProvider'); + } + return context; +} diff --git a/packages/@magic-ext/wallet-kit/src/extension.ts b/packages/@magic-ext/wallet-kit/src/extension.ts index c9ef85fc8..b10cf54bd 100644 --- a/packages/@magic-ext/wallet-kit/src/extension.ts +++ b/packages/@magic-ext/wallet-kit/src/extension.ts @@ -590,6 +590,13 @@ export class WalletKitExtension extends Extension.Internal<'walletKit'> { return this.sdk.auth.loginWithEmailOTP({ email, showUI: false, deviceCheckUI: false }); } + /** + * Login with SMS OTP + */ + public loginWithSMS(phoneNumber: string) { + return this.sdk.auth.loginWithSMS({ phoneNumber, showUI: false }); + } + /** * Login with Farcaster (whitelabel mode - no built-in UI). * Returns a PromiEvent that emits 'channel', 'success', and 'failed' events. diff --git a/packages/@magic-ext/wallet-kit/src/views/EmailOTPView.tsx b/packages/@magic-ext/wallet-kit/src/views/OtpView.tsx similarity index 55% rename from packages/@magic-ext/wallet-kit/src/views/EmailOTPView.tsx rename to packages/@magic-ext/wallet-kit/src/views/OtpView.tsx index d96beeeb5..617b0da57 100644 --- a/packages/@magic-ext/wallet-kit/src/views/EmailOTPView.tsx +++ b/packages/@magic-ext/wallet-kit/src/views/OtpView.tsx @@ -1,41 +1,48 @@ -import { Button, EmailWbr, IcoEmail, Text, VerifyPincode } from '@magiclabs/ui-components'; +import { Button, EmailWbr, IcoEmail, Text, VerifyPincode, IconSms } from '@magiclabs/ui-components'; import React, { useEffect, useState } from 'react'; import WidgetHeader from '../components/WidgetHeader'; import { useEmailLogin } from '../context/EmailLoginContext'; +import { useSmsLogin } from '../context/SmsLoginContext'; import { WidgetAction, WidgetState } from '../reducer'; import { VStack } from '@styled/jsx'; import { token } from '@styled/tokens'; -interface EmailOTPViewProps { +interface OtpViewProps { state: WidgetState; dispatch: React.Dispatch; } -export const EmailOTPView = ({ state, dispatch }: EmailOTPViewProps) => { - const { submitOTP, cancelLogin, resendEmailOTP } = useEmailLogin(); - const { emailLoginStatus, email, error } = state; +export const OtpView = ({ state, dispatch }: OtpViewProps) => { + const emailLogin = useEmailLogin(); + const smsLogin = useSmsLogin(); + const { otpLoginStatus, identifier, error, loginMethod } = state; const [isResending, setIsResending] = useState(false); const [showResendButton, setShowResendButton] = useState(false); - const isVerifying = emailLoginStatus === 'verifying_otp'; - const isSuccess = emailLoginStatus === 'success'; + const isSms = loginMethod === 'sms'; + const submitOTP = isSms ? smsLogin.submitOTP : emailLogin.submitOTP; + const cancelLogin = isSms ? smsLogin.cancelLogin : emailLogin.cancelLogin; + const resendOtp = isSms ? smsLogin.resendSmsOTP : emailLogin.resendEmailOTP; + + const isVerifying = otpLoginStatus === 'verifying_otp'; + const isSuccess = otpLoginStatus === 'success'; const onChangeOtp = () => { - dispatch({ type: 'RESET_EMAIL_ERROR' }); + dispatch({ type: 'RESET_OTP_ERROR' }); }; useEffect(() => { - if (emailLoginStatus === 'otp_sent') { + if (otpLoginStatus === 'otp_sent') { setIsResending(false); } - if (emailLoginStatus === 'expired_otp' || emailLoginStatus === 'max_attempts_reached') { + if (otpLoginStatus === 'expired_otp' || otpLoginStatus === 'max_attempts_reached') { setShowResendButton(true); } - }, [emailLoginStatus]); + }, [otpLoginStatus]); const handleResend = () => { setIsResending(true); - resendEmailOTP(); + resendOtp(); setShowResendButton(false); }; @@ -44,7 +51,11 @@ export const EmailOTPView = ({ state, dispatch }: EmailOTPViewProps) => { - + {isSms ? ( + + ) : ( + + )} { textAlign: 'center', }} > - {email && } + {identifier && (isSms ? identifier : )} { errorMessage={isResending ? '' : error ?? ''} > - {showResendButton &&