diff --git a/apps/web/test/setup.ts b/apps/web/test/setup.ts
index 2564691c80..9881d55857 100644
--- a/apps/web/test/setup.ts
+++ b/apps/web/test/setup.ts
@@ -11,6 +11,11 @@ if (typeof globalThis.ResizeObserver === "undefined") {
} as unknown as typeof ResizeObserver;
}
+// jsdom doesn't implement elementFromPoint; input-otp uses it internally.
+if (typeof document.elementFromPoint !== "function") {
+ document.elementFromPoint = () => null;
+}
+
// jsdom 29 / Node.js 22+ may not provide a proper Web Storage API.
// Create a proper localStorage mock if methods are missing.
if (
diff --git a/packages/views/auth/login-page.test.tsx b/packages/views/auth/login-page.test.tsx
index cc1d374e42..b351c09315 100644
--- a/packages/views/auth/login-page.test.tsx
+++ b/packages/views/auth/login-page.test.tsx
@@ -198,6 +198,23 @@ describe("LoginPage", () => {
expect(screen.getByText(/test@example.com/)).toBeInTheDocument();
});
+ it("autofocuses the OTP input when the code step opens", async () => {
+ mockSendCode.mockResolvedValueOnce(undefined);
+ renderWithI18n();
+
+ const user = userEvent.setup();
+ await user.type(screen.getByLabelText(/email/i), "test@example.com");
+ await user.click(screen.getByRole("button", { name: /continue/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/check your email/i)).toBeInTheDocument();
+ });
+
+ // The OTP field should be focused on mount so the user can type the code
+ // without clicking it first — important when repeatedly switching accounts.
+ expect(getOTPInput()).toHaveFocus();
+ });
+
it("shows error when sendCode fails", async () => {
mockSendCode.mockRejectedValueOnce(new Error("Rate limited"));
renderWithI18n();
diff --git a/packages/views/auth/login-page.tsx b/packages/views/auth/login-page.tsx
index 23f1bf486a..5a25e22ed0 100644
--- a/packages/views/auth/login-page.tsx
+++ b/packages/views/auth/login-page.tsx
@@ -349,6 +349,7 @@ export function LoginPage({
{