Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/moody-turtles-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"thirdweb": patch
---

Feature: Propagate failed sign in error message to the UI
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
import { userEvent } from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TEST_ACCOUNT_A } from "~test/test-wallets.js";
import { render, waitFor } from "../../../../../../test/src/react-render.js";
import { TEST_CLIENT } from "../../../../../../test/src/test-clients.js";
import { createWallet } from "../../../../../wallets/create-wallet.js";
import { useActiveWallet } from "../../../../core/hooks/wallets/useActiveWallet.js";
import type { ConnectLocale } from "../locale/types.js";
import { SignatureScreen } from "./SignatureScreen.js";

const mockAuth = vi.hoisted(() => ({
doLogin: vi.fn().mockResolvedValue(undefined),
doLogout: vi.fn().mockResolvedValue(undefined),
getLoginPayload: vi.fn().mockResolvedValue(undefined),
isLoggedIn: vi.fn().mockResolvedValue(true),
}));

vi.mock("../../../../core/hooks/auth/useSiweAuth", () => ({
useSiweAuth: () => mockAuth,
}));

vi.mock("../../../../core/hooks/wallets/useActiveWallet", () => ({
useActiveWallet: vi.fn().mockReturnValue(createWallet("io.metamask")),
}));

vi.mock("../../../../core/hooks/wallets/useActiveAccount", () => ({
useActiveAccount: () => vi.fn().mockReturnValue(TEST_ACCOUNT_A),
}));

vi.mock("../../../../core/hooks/wallets/useAdminWallet", () => ({
useAdminWallet: () => vi.fn().mockReturnValue(null),
}));

const mockConnectLocale = {
signatureScreen: {
title: "Sign In",
instructionScreen: {
title: "Sign Message",
instruction: "Please sign the message",
signInButton: "Sign In",
disconnectWallet: "Disconnect",
},
signingScreen: {
title: "Signing",
inProgress: "Signing in progress...",
failedToSignIn: "Failed to sign in",
prompt: "Please check your wallet",
tryAgain: "Try Again",
},
},
agreement: {
prefix: "By connecting, you agree to our",
termsOfService: "Terms of Service",
and: "and",
privacyPolicy: "Privacy Policy",
},
} as unknown as ConnectLocale;

describe("SignatureScreen", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAuth.doLogin.mockResolvedValue(undefined);
});

it("renders initial state correctly", () => {
const { getByTestId } = render(
<SignatureScreen
onDone={() => {}}
modalSize="wide"
connectLocale={mockConnectLocale}
client={TEST_CLIENT}
auth={mockAuth}
/>,
{ setConnectedWallet: true },
);

expect(getByTestId("sign-in-button")).toBeInTheDocument();
expect(getByTestId("disconnect-button")).toBeInTheDocument();
});

it("handles signing flow", async () => {
const onDoneMock = vi.fn();
const { getByRole, getByText } = render(
<SignatureScreen
onDone={onDoneMock}
modalSize="wide"
connectLocale={mockConnectLocale}
client={TEST_CLIENT}
auth={mockAuth}
/>,
{ setConnectedWallet: true },
);

const signInButton = getByRole("button", { name: "Sign In" });
await userEvent.click(signInButton);

// Should show signing in progress
await waitFor(() => {
expect(getByText("Signing in progress...")).toBeInTheDocument();
});
});

it("shows loading state when wallet is undefined", async () => {
vi.mocked(useActiveWallet).mockReturnValueOnce(undefined);

const { queryByTestId } = render(
<SignatureScreen
onDone={() => {}}
modalSize="wide"
connectLocale={mockConnectLocale}
client={TEST_CLIENT}
auth={mockAuth}
/>,
{ setConnectedWallet: true },
);

expect(queryByTestId("sign-in-button")).not.toBeInTheDocument();
});

it("handles error state", async () => {
mockAuth.doLogin.mockRejectedValueOnce(new Error("Signing failed"));
const { getByTestId, getByRole, getByText } = render(
<SignatureScreen
onDone={() => {}}
modalSize="wide"
connectLocale={mockConnectLocale}
client={TEST_CLIENT}
auth={mockAuth}
/>,
{ setConnectedWallet: true },
);

const signInButton = await waitFor(() => {
return getByTestId("sign-in-button");
});
await userEvent.click(signInButton);

// Should show error state
await waitFor(
() => {
expect(getByText("Signing failed")).toBeInTheDocument();
expect(getByRole("button", { name: "Try Again" })).toBeInTheDocument();
},
{
timeout: 2000,
},
);
});

describe("HeadlessSignIn", () => {
const mockWallet = createWallet("inApp");
beforeEach(() => {
vi.mocked(useActiveWallet).mockReturnValue(mockWallet);
});

it("automatically triggers sign in on mount", async () => {
render(
<SignatureScreen
onDone={() => {}}
modalSize="wide"
connectLocale={mockConnectLocale}
client={TEST_CLIENT}
auth={mockAuth}
/>,
{ setConnectedWallet: true },
);

await waitFor(() => {
expect(mockAuth.doLogin).toHaveBeenCalledTimes(1);
});
});

it("shows signing message during signing state", async () => {
const { getByText } = render(
<SignatureScreen
onDone={() => {}}
modalSize="wide"
connectLocale={mockConnectLocale}
client={TEST_CLIENT}
auth={mockAuth}
/>,
{ setConnectedWallet: true },
);

await waitFor(() => {
expect(getByText("Signing")).toBeInTheDocument();
});
});

it("shows error and retry button when signing fails", async () => {
mockAuth.doLogin.mockRejectedValueOnce(
new Error("Headless signing failed"),
);

const { getByText, getByRole } = render(
<SignatureScreen
onDone={() => {}}
modalSize="wide"
connectLocale={mockConnectLocale}
client={TEST_CLIENT}
auth={mockAuth}
/>,
{ setConnectedWallet: true },
);

await waitFor(
() => {
expect(getByText("Headless signing failed")).toBeInTheDocument();
expect(
getByRole("button", { name: "Try Again" }),
).toBeInTheDocument();
},
{ timeout: 2000 },
);
});

it("allows retry after failure", async () => {
mockAuth.doLogin
.mockRejectedValueOnce(new Error("Failed first time"))
.mockResolvedValueOnce(undefined);

const { getByRole, getByText } = render(
<SignatureScreen
onDone={() => {}}
modalSize="wide"
connectLocale={mockConnectLocale}
client={TEST_CLIENT}
auth={mockAuth}
/>,
{ setConnectedWallet: true },
);

// Wait for initial failure
await waitFor(
() => {
expect(getByText("Failed first time")).toBeInTheDocument();
},
{ timeout: 2000 },
);

// Click retry
const retryButton = getByRole("button", { name: "Try Again" });
await userEvent.click(retryButton);

// Should show loading again
await waitFor(() => {
expect(getByText("Signing")).toBeInTheDocument();
});

// Should have called login twice
expect(mockAuth.doLogin).toHaveBeenCalledTimes(2);
});

it("allows disconnecting wallet after failure", async () => {
const mockDisconnect = vi.fn().mockResolvedValue(undefined);
mockAuth.doLogin.mockRejectedValueOnce(new Error("Failed"));
vi.mocked(useActiveWallet).mockReturnValueOnce({
...createWallet("io.metamask"),
disconnect: mockDisconnect,
});

const { getByTestId } = render(
<SignatureScreen
onDone={() => {}}
modalSize="wide"
connectLocale={mockConnectLocale}
client={TEST_CLIENT}
auth={mockAuth}
/>,
{ setConnectedWallet: true },
);

// Wait for failure and click disconnect
await waitFor(
() => {
return getByTestId("disconnect-button");
},
{ timeout: 2000 },
).then((button) => userEvent.click(button));

// Should have attempted to disconnect
await waitFor(() => {
expect(mockDisconnect).toHaveBeenCalled();
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -51,24 +51,26 @@ export const SignatureScreen: React.FC<{
const adminWallet = useAdminWallet();
const activeAccount = useActiveAccount();
const siweAuth = useSiweAuth(wallet, activeAccount, props.auth);
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState<string | undefined>(undefined);
const [status, setStatus] = useState<Status>(error ? "failed" : "idle");
const { disconnect } = useDisconnect();
const locale = connectLocale.signatureScreen;

const signIn = useCallback(async () => {
try {
setError(undefined);
setStatus("signing");
await siweAuth.doLogin();
onDone?.();
} catch (err) {
await wait(1000);
setError((err as Error).message);
setStatus("failed");
console.error("failed to log in", err);
}
}, [onDone, siweAuth]);

if (!wallet) {
return <LoadingScreen />;
return <LoadingScreen data-testid="loading-screen" />;
}

if (
Expand All @@ -78,6 +80,7 @@ export const SignatureScreen: React.FC<{
) {
return (
<HeadlessSignIn
error={error}
signIn={signIn}
status={status}
connectLocale={connectLocale}
Expand Down Expand Up @@ -126,6 +129,7 @@ export const SignatureScreen: React.FC<{
<Button
fullWidth
variant="accent"
data-testid="sign-in-button"
onClick={signIn}
style={{
alignItems: "center",
Expand All @@ -138,6 +142,7 @@ export const SignatureScreen: React.FC<{
<Button
fullWidth
variant="secondary"
data-testid="disconnect-button"
onClick={() => {
disconnect(wallet);
}}
Expand All @@ -162,7 +167,7 @@ export const SignatureScreen: React.FC<{
<Container flex="column" gap="md" animate="fadein" key={status}>
<Text size="lg" center color="primaryText">
{status === "failed"
? locale.signingScreen.failedToSignIn
? error || locale.signingScreen.failedToSignIn
: locale.signingScreen.inProgress}
</Text>

Expand Down Expand Up @@ -224,12 +229,14 @@ export const SignatureScreen: React.FC<{

function HeadlessSignIn({
signIn,
error,
status,
connectLocale,
wallet,
}: {
signIn: () => void;
status: Status;
error: string | undefined;
connectLocale: ConnectLocale;
wallet: Wallet;
}) {
Expand Down Expand Up @@ -262,7 +269,7 @@ function HeadlessSignIn({
<Container>
<Spacer y="lg" />
<Text size="lg" center color="danger">
{locale.signingScreen.failedToSignIn}
{error || locale.signingScreen.failedToSignIn}
</Text>

<Spacer y="lg" />
Expand All @@ -288,6 +295,7 @@ function HeadlessSignIn({
onClick={() => {
disconnect(wallet);
}}
data-testid="disconnect-button"
style={{
alignItems: "center",
padding: spacing.md,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export function isEcosystemWallet(wallet: string): wallet is EcosystemWalletId;
/**
* Checks if the given wallet is an ecosystem wallet.
*
* @param {string} walletId - The wallet ID to check.
* @param {Wallet | string} wallet - The wallet or wallet ID to check.
* @returns {boolean} True if the wallet is an ecosystem wallet, false otherwise.
* @internal
*/
Expand Down
Loading