diff --git a/packages/@magic-ext/wallet-kit/package.json b/packages/@magic-ext/wallet-kit/package.json
index e4e28ac11..5c2b19bff 100644
--- a/packages/@magic-ext/wallet-kit/package.json
+++ b/packages/@magic-ext/wallet-kit/package.json
@@ -48,11 +48,12 @@
"@styled/patterns": "./styled-system/patterns"
},
"dependencies": {
- "@magiclabs/ui-components": "^1.49.3",
+ "@magiclabs/ui-components": "^1.50.0",
"@reown/appkit": "^1.8.0",
"@reown/appkit-adapter-wagmi": "^1.8.0",
"@wagmi/core": "^2.0.0",
"@walletconnect/ethereum-provider": "^2.23.0",
+ "libphonenumber-js": "^1.12.37",
"wagmi": "^2.0.0"
},
"peerDependencies": {
diff --git a/packages/@magic-ext/wallet-kit/src/MagicWidget.tsx b/packages/@magic-ext/wallet-kit/src/MagicWidget.tsx
index 42adc2e70..ce57d46eb 100644
--- a/packages/@magic-ext/wallet-kit/src/MagicWidget.tsx
+++ b/packages/@magic-ext/wallet-kit/src/MagicWidget.tsx
@@ -12,14 +12,16 @@ import AdditionalProvidersView from './views/AdditionalProvidersView';
import { getExtensionInstance } from './extension';
import { EmailLoginProvider } from './context/EmailLoginContext';
import { OAuthLoginProvider } from './context/OAuthLoginContext';
+import { SmsLoginProvider } from './context/SmsLoginContext';
import { WidgetConfigProvider } from './context/WidgetConfigContext';
-import { EmailOTPView } from './views/EmailOTPView';
+import { OtpView } from './views/OtpView';
import { DeviceVerificationView } from './views/DeviceVerificationView';
import { LoginSuccessView } from './views/LoginSuccessView';
import { MFAView } from './views/MfaView';
import { RecoveryCodeView } from './views/RecoveryCode';
import { LostRecoveryCode } from './views/LostRecoveryCode';
import { WalletConnectView } from './views/WalletConnectView';
+import { SmsLoginView } from './views/SmsLoginView';
import { FarcasterPendingView } from './views/FarcasterPendingView';
import { FarcasterSuccessView } from './views/FarcasterSuccessView';
import { FarcasterFailedView } from './views/FarcasterFailedView';
@@ -57,10 +59,12 @@ function WidgetContent({
const renderView = () => {
switch (state.view) {
case 'login':
- return ;
+ return ;
+ case 'sms_login':
+ return ;
case 'wallet_pending':
if (!state.selectedProvider) {
- return ;
+ return ;
}
return (
;
case 'oauth_pending':
if (!state.selectedProvider) {
- return ;
+ return ;
}
return (
;
- case 'email_otp_pending':
- return ;
+ case 'otp_pending':
+ return ;
case 'device_verification':
return ;
case 'mfa_pending':
@@ -105,20 +109,22 @@ function WidgetContent({
case 'farcaster_failed':
return ;
default:
- return ;
+ return ;
}
};
return (
-
-
-
- {renderView()}
-
-
-
-
+
+
+
+
+ {renderView()}
+
+
+
+
+
);
}
diff --git a/packages/@magic-ext/wallet-kit/src/components/ContinueWithSmsButton.tsx b/packages/@magic-ext/wallet-kit/src/components/ContinueWithSmsButton.tsx
new file mode 100644
index 000000000..e92d385b7
--- /dev/null
+++ b/packages/@magic-ext/wallet-kit/src/components/ContinueWithSmsButton.tsx
@@ -0,0 +1,32 @@
+import { ButtonContainer, IcoMessage, IcoPhone, Text } from '@magiclabs/ui-components';
+import { css } from '@styled/css';
+import { Box, Flex } from '@styled/jsx';
+import { token } from '@styled/tokens';
+import React from 'react';
+import { getExtensionInstance } from '../extension';
+
+export const ContinueWithSmsButton = ({ onClick }: { onClick: () => void }) => {
+ const config = getExtensionInstance().getConfig();
+ const isDarkMode = config?.theme.themeColor === 'dark';
+
+ return (
+
+
+
+
+ Continue with SMS
+
+
+
+ );
+};
diff --git a/packages/@magic-ext/wallet-kit/src/components/EmailInput.tsx b/packages/@magic-ext/wallet-kit/src/components/EmailInput.tsx
index b97fb5802..d12164fdb 100644
--- a/packages/@magic-ext/wallet-kit/src/components/EmailInput.tsx
+++ b/packages/@magic-ext/wallet-kit/src/components/EmailInput.tsx
@@ -8,33 +8,39 @@ import { Box } from '@styled/jsx';
import { token } from '@styled/tokens';
import { getExtensionInstance } from 'src/extension';
-export const EmailInput = () => {
+interface EmailInputProps {
+ error?: string;
+ isLoading?: boolean;
+}
+
+export const EmailInput = ({ error: externalError, isLoading }: EmailInputProps) => {
const { startEmailLogin } = useEmailLogin();
const [email, setEmail] = useState('');
- const [isValidating, setIsValidating] = useState(false);
- const [error, setError] = useState(null);
+ const [localError, setLocalError] = useState(null);
const [disabled, setDisabled] = useState(true);
const config = getExtensionInstance().getConfig();
const isDarkMode = config?.theme.themeColor === 'dark';
+ // Use external error if available, otherwise fall back to local error
+ const displayError = externalError || localError;
+
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
if (!isValidEmail(email)) {
- setError(RpcErrorMessage.MalformedEmail);
+ setLocalError(RpcErrorMessage.MalformedEmail);
return;
} else if (isSanctionedEmail(email)) {
- setError(RpcErrorMessage.SanEmail);
+ setLocalError(RpcErrorMessage.SanEmail);
return;
}
setDisabled(true);
- setIsValidating(true);
startEmailLogin(email);
};
const handleInput = (e: string) => {
- setError(null);
+ setLocalError(null);
setDisabled(!e.length);
setEmail(e.trim());
};
@@ -52,7 +58,7 @@ export const EmailInput = () => {
variant="text"
textStyle="neutral"
disabled={disabled}
- validating={isValidating}
+ validating={isLoading}
type="submit"
>
@@ -62,9 +68,9 @@ export const EmailInput = () => {
- {error && (
+ {displayError && (
- {error}
+ {displayError}
)}
diff --git a/packages/@magic-ext/wallet-kit/src/components/ProviderButton.tsx b/packages/@magic-ext/wallet-kit/src/components/ProviderButton.tsx
index 48004292c..7f0b7292e 100644
--- a/packages/@magic-ext/wallet-kit/src/components/ProviderButton.tsx
+++ b/packages/@magic-ext/wallet-kit/src/components/ProviderButton.tsx
@@ -8,9 +8,10 @@ interface ProviderButtonProps {
Icon: ElementType;
onPress: () => void;
hideLabel?: boolean;
+ center?: boolean;
}
-export const ProviderButton = ({ label, Icon, onPress, hideLabel }: ProviderButtonProps) => {
+export const ProviderButton = ({ label, Icon, onPress, hideLabel, center }: ProviderButtonProps) => {
return (
-
+
{!hideLabel && label && (
diff --git a/packages/@magic-ext/wallet-kit/src/context/EmailLoginContext.tsx b/packages/@magic-ext/wallet-kit/src/context/EmailLoginContext.tsx
index 4dec0213f..f577ebd66 100644
--- a/packages/@magic-ext/wallet-kit/src/context/EmailLoginContext.tsx
+++ b/packages/@magic-ext/wallet-kit/src/context/EmailLoginContext.tsx
@@ -43,7 +43,7 @@ export function EmailLoginProvider({ children, dispatch }: EmailLoginProviderPro
const startEmailLogin = useCallback(
(email: string) => {
emailRef.current = email;
- dispatch({ type: 'EMAIL_OTP_START', email });
+ dispatch({ type: 'OTP_START', identifier: email, loginMethod: 'email' });
try {
const extension = getExtensionInstance();
@@ -56,17 +56,17 @@ export function EmailLoginProvider({ children, dispatch }: EmailLoginProviderPro
// OTP was sent successfully
handle.on(LoginWithEmailOTPEventOnReceived.EmailOTPSent, () => {
- dispatch({ type: 'EMAIL_OTP_SENT' });
+ dispatch({ type: 'OTP_SENT' });
});
// Invalid OTP entered
handle.on(LoginWithEmailOTPEventOnReceived.InvalidEmailOtp, () => {
- dispatch({ type: 'EMAIL_OTP_INVALID' });
+ dispatch({ type: 'OTP_INVALID' });
});
// OTP has expired
handle.on(LoginWithEmailOTPEventOnReceived.ExpiredEmailOtp, () => {
- dispatch({ type: 'EMAIL_OTP_EXPIRED' });
+ dispatch({ type: 'OTP_EXPIRED' });
});
// Login throttled (too many attempts)
@@ -76,7 +76,7 @@ export function EmailLoginProvider({ children, dispatch }: EmailLoginProviderPro
// Max attempts reached
handle.on(LoginWithEmailOTPEventOnReceived.MaxAttemptsReached, () => {
- dispatch({ type: 'EMAIL_OTP_MAX_ATTEMPTS_REACHED' });
+ dispatch({ type: 'OTP_MAX_ATTEMPTS_REACHED' });
});
// ==========================================
@@ -157,7 +157,7 @@ export function EmailLoginProvider({ children, dispatch }: EmailLoginProviderPro
const submitOTP = useCallback(
(otp: string) => {
if (handleRef.current) {
- dispatch({ type: 'EMAIL_OTP_VERIFYING' });
+ dispatch({ type: 'OTP_VERIFYING' });
handleRef.current.emit(LoginWithEmailOTPEventEmit.VerifyEmailOtp, otp);
}
},
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..4c0886da5
--- /dev/null
+++ b/packages/@magic-ext/wallet-kit/src/context/SmsLoginContext.tsx
@@ -0,0 +1,265 @@
+import {
+ DeviceVerificationEventEmit,
+ DeviceVerificationEventOnReceived,
+ LoginWithSmsOTPEventEmit,
+ LoginWithSmsOTPEventOnReceived,
+} from '@magic-sdk/types';
+import React, { createContext, ReactNode, useCallback, useContext, useRef, useState } from 'react';
+import { getExtensionInstance } from '../extension';
+import { WidgetAction } from '../reducer';
+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 sms 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
+ // ==========================================
+
+ handle.on(LoginWithSmsOTPEventOnReceived.MfaSentHandle, () => {
+ dispatch({ type: 'MFA_REQUIRED' });
+ });
+
+ handle.on(LoginWithSmsOTPEventOnReceived.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, dispatch]);
+
+ 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 04261440d..bf2500380 100644
--- a/packages/@magic-ext/wallet-kit/src/extension.ts
+++ b/packages/@magic-ext/wallet-kit/src/extension.ts
@@ -629,6 +629,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/hooks/useCancelLogin.ts b/packages/@magic-ext/wallet-kit/src/hooks/useCancelLogin.ts
new file mode 100644
index 000000000..3d02b472d
--- /dev/null
+++ b/packages/@magic-ext/wallet-kit/src/hooks/useCancelLogin.ts
@@ -0,0 +1,32 @@
+import { useEmailLogin } from '../context/EmailLoginContext';
+import { useOAuthLogin } from '../context/OAuthLoginContext';
+import { useSmsLogin } from '../context/SmsLoginContext';
+
+interface UseCancelLoginResult {
+ cancelLogin: () => void;
+}
+
+export function useCancelLogin(): UseCancelLoginResult {
+ const oauthContext = useOAuthLogin();
+ const emailContext = useEmailLogin();
+ const smsContext = useSmsLogin();
+
+ // If the OAuth context has an active MFA flow, use it
+ if (oauthContext.isMfaActive) {
+ return {
+ cancelLogin: oauthContext.cancelLogin,
+ };
+ }
+
+ // Use SMS context for MFA if SMS login is active
+ if (smsContext.isSmsLoginActive) {
+ return {
+ cancelLogin: smsContext.cancelLogin,
+ };
+ }
+
+ // Default to the email login context
+ return {
+ cancelLogin: emailContext.cancelLogin,
+ };
+}
diff --git a/packages/@magic-ext/wallet-kit/src/hooks/useMfa.ts b/packages/@magic-ext/wallet-kit/src/hooks/useMfa.ts
index 7402deb80..accbf9ced 100644
--- a/packages/@magic-ext/wallet-kit/src/hooks/useMfa.ts
+++ b/packages/@magic-ext/wallet-kit/src/hooks/useMfa.ts
@@ -1,5 +1,6 @@
import { useEmailLogin } from '../context/EmailLoginContext';
import { useOAuthLogin } from '../context/OAuthLoginContext';
+import { useSmsLogin } from '../context/SmsLoginContext';
interface UseMfaResult {
submitMFA: (totp: string) => void;
@@ -11,6 +12,7 @@ interface UseMfaResult {
export function useMfa(): UseMfaResult {
const oauthContext = useOAuthLogin();
const emailContext = useEmailLogin();
+ const smsContext = useSmsLogin();
// If the OAuth context has an active MFA flow, use it
if (oauthContext.isMfaActive) {
@@ -22,6 +24,16 @@ export function useMfa(): UseMfaResult {
};
}
+ // Use SMS context for MFA if SMS login is active
+ if (smsContext.isSmsLoginActive) {
+ return {
+ submitMFA: smsContext.submitMFA,
+ lostDevice: smsContext.lostDevice,
+ submitRecoveryCode: smsContext.submitRecoveryCode,
+ cancelLogin: smsContext.cancelLogin,
+ };
+ }
+
// Default to the email login context
return {
submitMFA: emailContext.submitMFA,
diff --git a/packages/@magic-ext/wallet-kit/src/index.ts b/packages/@magic-ext/wallet-kit/src/index.ts
index 051593272..a4cb8af14 100644
--- a/packages/@magic-ext/wallet-kit/src/index.ts
+++ b/packages/@magic-ext/wallet-kit/src/index.ts
@@ -8,6 +8,7 @@ export {
type MagicWidgetProps,
type LoginResult,
type EmailLoginResult,
+ type SmsLoginResult,
type OAuthLoginResult,
type WalletLoginResult,
type FarcasterLoginResult,
diff --git a/packages/@magic-ext/wallet-kit/src/reducer.ts b/packages/@magic-ext/wallet-kit/src/reducer.ts
index 274b23783..af8afa40a 100644
--- a/packages/@magic-ext/wallet-kit/src/reducer.ts
+++ b/packages/@magic-ext/wallet-kit/src/reducer.ts
@@ -5,11 +5,12 @@ import { LoginProvider, OAuthProvider, ThirdPartyWallet, ThirdPartyWallets } fro
export type View =
| 'login'
| 'otp'
+ | 'sms_login'
| 'additional_providers'
| 'wallet_pending'
| 'walletconnect_pending'
| 'oauth_pending'
- | 'email_otp_pending'
+ | 'otp_pending'
| 'device_verification'
| 'mfa_pending'
| 'recovery_code'
@@ -19,7 +20,7 @@ export type View =
| 'farcaster_success'
| 'farcaster_failed';
-export type EmailLoginStatus =
+export type OtpLoginStatus =
| 'idle'
| 'sending'
| 'otp_sent'
@@ -42,13 +43,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 +60,14 @@ 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' }
+ | { type: 'GO_TO_SMS_LOGIN' }
+ // 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 +80,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 +96,7 @@ export type WidgetAction =
export const initialState: WidgetState = {
view: 'login',
- emailLoginStatus: 'idle',
+ otpLoginStatus: 'idle',
};
export function widgetReducer(state: WidgetState, action: WidgetAction): WidgetState {
@@ -103,52 +107,60 @@ 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':
+ case 'GO_TO_SMS_LOGIN':
return {
...state,
- email: action.email,
- emailLoginStatus: 'sending',
+ view: 'sms_login',
error: undefined,
};
- case 'EMAIL_OTP_SENT':
+ // OTP flow (email or SMS)
+ case 'OTP_START':
return {
...state,
- view: 'email_otp_pending',
- emailLoginStatus: 'otp_sent',
+ identifier: action.identifier,
+ loginMethod: action.loginMethod || 'email',
+ otpLoginStatus: 'sending',
error: undefined,
};
- case 'EMAIL_OTP_VERIFYING':
+ case 'OTP_SENT':
return {
...state,
- emailLoginStatus: 'verifying_otp',
+ view: 'otp_pending',
+ otpLoginStatus: 'otp_sent',
error: undefined,
};
- case 'EMAIL_OTP_INVALID':
+ case 'OTP_VERIFYING':
return {
...state,
- emailLoginStatus: 'invalid_otp',
+ otpLoginStatus: 'verifying_otp',
+ error: undefined,
+ };
+
+ case 'OTP_INVALID':
+ return {
+ ...state,
+ 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 +168,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 +176,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 +198,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 +220,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 +242,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 +250,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 2d8ebf47e..40be00b40 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.
diff --git a/packages/@magic-ext/wallet-kit/src/views/DeviceVerificationView.tsx b/packages/@magic-ext/wallet-kit/src/views/DeviceVerificationView.tsx
index 3c1c3edee..f81772772 100644
--- a/packages/@magic-ext/wallet-kit/src/views/DeviceVerificationView.tsx
+++ b/packages/@magic-ext/wallet-kit/src/views/DeviceVerificationView.tsx
@@ -6,6 +6,7 @@ import { token } from '@styled/tokens';
import { useEmailLogin } from '../context/EmailLoginContext';
import { WidgetAction, WidgetState } from '../reducer';
import WidgetHeader from '../components/WidgetHeader';
+import { useCancelLogin } from 'src/hooks/useCancelLogin';
interface DeviceVerificationViewProps {
state: WidgetState;
@@ -13,14 +14,17 @@ interface DeviceVerificationViewProps {
}
export const DeviceVerificationView = ({ state, dispatch }: DeviceVerificationViewProps) => {
- const { cancelLogin } = useEmailLogin();
- const { emailLoginStatus, email } = state;
+ const { cancelLogin } = useCancelLogin();
+ const { otpLoginStatus, identifier, loginMethod } = state;
useEffect(() => {
- if (emailLoginStatus === 'device_approved') {
- dispatch({ type: 'EMAIL_OTP_SENT' });
+ if (otpLoginStatus === 'device_approved') {
+ dispatch({ type: 'OTP_SENT' });
}
- }, [emailLoginStatus]);
+ }, [otpLoginStatus]);
+
+ // For SMS login, device verification is done via SMS, not email
+ const isSms = loginMethod === 'sms';
return (
<>
@@ -51,7 +55,7 @@ export const DeviceVerificationView = ({ state, dispatch }: DeviceVerificationVi
fontWeight: '600',
}}
>
- {email}
+ {isSms ? 'your phone' : identifier}