Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
# (Fri Feb 20 2026)

#### πŸš€ Enhancement

- `@magic-ext/wallet-kit@0.5.0`
- adds account switching for external wallets [#1038](https://github.com/magiclabs/magic-js/pull/1038) ([@joshuascan](https://github.com/joshuascan))

#### Authors: 1

- Josh Scanlan ([@joshuascan](https://github.com/joshuascan))

---

# (Wed Feb 18 2026)

#### πŸ› Bug Fix
Expand Down
12 changes: 12 additions & 0 deletions packages/@magic-ext/wallet-kit/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
# v0.5.0 (Fri Feb 20 2026)

#### πŸš€ Enhancement

- adds account switching for external wallets [#1038](https://github.com/magiclabs/magic-js/pull/1038) ([@joshuascan](https://github.com/joshuascan))

#### Authors: 1

- Josh Scanlan ([@joshuascan](https://github.com/joshuascan))

---

# v0.4.0 (Tue Feb 10 2026)

#### πŸš€ Enhancement
Expand Down
3 changes: 2 additions & 1 deletion packages/@magic-ext/wallet-kit/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@magic-ext/wallet-kit",
"useCustomBuild": true,
"version": "0.4.1",
"version": "0.5.0",
"description": "Magic SDK Wallet Kit Extension",
"author": "Magic <team@magic.link> (https://magic.link/)",
"license": "MIT",
Expand Down Expand Up @@ -53,6 +53,7 @@
"@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": {
Expand Down
42 changes: 27 additions & 15 deletions packages/@magic-ext/wallet-kit/src/MagicWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,18 @@ 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 { WebAuthnLoginProvider } from './context/WebAuthnLoginContext';
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 { WebAuthnLoginView } from './views/WebAuthnLoginView';
import { FarcasterPendingView } from './views/FarcasterPendingView';
import { FarcasterSuccessView } from './views/FarcasterSuccessView';
import { FarcasterFailedView } from './views/FarcasterFailedView';
Expand Down Expand Up @@ -57,10 +61,14 @@ function WidgetContent({
const renderView = () => {
switch (state.view) {
case 'login':
return <LoginView dispatch={dispatch} />;
return <LoginView dispatch={dispatch} state={state} />;
case 'sms_login':
return <SmsLoginView state={state} />;
case 'webauthn_login':
return <WebAuthnLoginView state={state} />;
case 'wallet_pending':
if (!state.selectedProvider) {
return <LoginView dispatch={dispatch} />;
return <LoginView dispatch={dispatch} state={state} />;
}
return (
<WalletPendingView
Expand All @@ -74,7 +82,7 @@ function WidgetContent({
return <WalletConnectView key="walletconnect" dispatch={dispatch} />;
case 'oauth_pending':
if (!state.selectedProvider) {
return <LoginView dispatch={dispatch} />;
return <LoginView dispatch={dispatch} state={state} />;
}
return (
<OAuthPendingView
Expand All @@ -86,8 +94,8 @@ function WidgetContent({
);
case 'additional_providers':
return <AdditionalProvidersView dispatch={dispatch} />;
case 'email_otp_pending':
return <EmailOTPView state={state} dispatch={dispatch} />;
case 'otp_pending':
return <OtpView state={state} dispatch={dispatch} />;
case 'device_verification':
return <DeviceVerificationView state={state} dispatch={dispatch} />;
case 'mfa_pending':
Expand All @@ -105,20 +113,24 @@ function WidgetContent({
case 'farcaster_failed':
return <FarcasterFailedView state={state} dispatch={dispatch} />;
default:
return <LoginView dispatch={dispatch} />;
return <LoginView dispatch={dispatch} state={state} />;
}
};

return (
<EmailLoginProvider dispatch={dispatch}>
<OAuthLoginProvider dispatch={dispatch}>
<Modal isWidget fullscreen={isModal && isMobile}>
<VStack width="full" minWidth="380px">
{renderView()}
<Footer showLogo={showFooterLogo} />
</VStack>
</Modal>
</OAuthLoginProvider>
<SmsLoginProvider dispatch={dispatch}>
<WebAuthnLoginProvider dispatch={dispatch}>
<OAuthLoginProvider dispatch={dispatch}>
<Modal isWidget fullscreen={isModal && isMobile}>
<VStack width="full" minWidth="380px">
{renderView()}
<Footer showLogo={showFooterLogo} />
</VStack>
</Modal>
</OAuthLoginProvider>
</WebAuthnLoginProvider>
</SmsLoginProvider>
</EmailLoginProvider>
);
}
Expand Down
26 changes: 16 additions & 10 deletions packages/@magic-ext/wallet-kit/src/components/EmailInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
const [localError, setLocalError] = useState<string | null>(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<HTMLFormElement>) => {
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());
};
Expand All @@ -52,7 +58,7 @@ export const EmailInput = () => {
variant="text"
textStyle="neutral"
disabled={disabled}
validating={isValidating}
validating={isLoading}
type="submit"
>
<Button.LeadingIcon color={token('colors.text.tertiary')}>
Expand All @@ -62,9 +68,9 @@ export const EmailInput = () => {
</TextInput.ActionIcon>
</TextInput>
</Box>
{error && (
{displayError && (
<Text variant="error" size="sm" styles={{ textAlign: 'center' }}>
{error}
{displayError}
</Text>
)}
</form>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<ButtonContainer
onPress={onPress}
Expand All @@ -23,7 +24,7 @@ export const ProviderButton = ({ label, Icon, onPress, hideLabel }: ProviderButt
_hover: { bg: 'neutral.tertiary' },
})}
>
<Flex gap={3} w="full" justifyContent={hideLabel ? 'center' : 'flex-start'} alignItems="center">
<Flex gap={3} w="full" justifyContent={hideLabel || center ? 'center' : 'flex-start'} alignItems="center">
<Icon width={24} height={24} />
{!hideLabel && label && (
<Text fontWeight="medium" styles={{ lineHeight: '1.5rem' }}>
Expand Down
81 changes: 81 additions & 0 deletions packages/@magic-ext/wallet-kit/src/components/SmsInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Button, IcoArrowRight, PhoneInput, Text } from '@magiclabs/ui-components';
import { Box } from '@styled/jsx';
import { vstack } from '@styled/patterns';
import { token } from '@styled/tokens';
import { isValidPhoneNumber } from 'libphonenumber-js';
import React, { FormEvent, useState } from 'react';
import { RpcErrorMessage } from 'src/types';
import { useSmsLogin } from '../context/SmsLoginContext';

interface SmsInputProps {
error?: string;
isLoading?: boolean;
}

export const SmsInput = ({ error: externalError, isLoading }: SmsInputProps) => {
const { startSmsLogin } = useSmsLogin();
const [phoneNumber, setPhoneNumber] = useState('');
const [localError, setLocalError] = useState<string | null>(null);

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<HTMLFormElement>) => {
event.preventDefault();

if (!isValidPhoneNumber(phoneNumber)) {
setLocalError(RpcErrorMessage.InvalidPhoneNumber);
return;
}

startSmsLogin(phoneNumber);
};

const handleInput = (value: string) => {
setLocalError(null);
setPhoneNumber(value.trim());
};

const handlePhoneChange = (phone: string) => {
handleInput(phone);
};

return (
<form onSubmit={handleSubmit} className={vstack({ w: 'full' })}>
<Box w="full" maxW="25rem" style={{ position: 'relative' }}>
<PhoneInput onChange={handlePhoneChange} autoFocus={false} errorMessage={undefined} />

<Box
style={{
position: 'absolute',
right: token('spacing.3'),
top: '0',
height: '100%',
display: 'flex',
alignItems: 'center',
}}
>
<Button
aria-label="login-submit-button"
variant="text"
textStyle="neutral"
disabled={!isPhoneValid}
validating={isLoading}
type="submit"
>
<Button.LeadingIcon color={token('colors.text.tertiary')}>
<IcoArrowRight />
</Button.LeadingIcon>
</Button>
</Box>
</Box>
{displayError && (
<Text variant="error" size="sm" styles={{ textAlign: 'center' }}>
{displayError}
</Text>
)}
</form>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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)
Expand All @@ -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' });
});

// ==========================================
Expand Down Expand Up @@ -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);
}
},
Expand Down
Loading