diff --git a/.changeset/login-email-otp-letters.md b/.changeset/login-email-otp-letters.md new file mode 100644 index 0000000..dadb7f4 --- /dev/null +++ b/.changeset/login-email-otp-letters.md @@ -0,0 +1,5 @@ +--- +"seamless-cli": patch +--- + +Fix `seamless login` rejecting valid email OTP codes. Email OTPs are six letters, but the code prompt only accepted digits, so no real email code could be entered. The prompt is now channel-aware: it accepts a six-letter code (case-insensitive, normalized to uppercase) for email logins and a numeric code for phone logins, with matching placeholder text. diff --git a/src/commands/login.test.ts b/src/commands/login.test.ts index 9d7deb9..04ecb1d 100644 --- a/src/commands/login.test.ts +++ b/src/commands/login.test.ts @@ -256,7 +256,7 @@ describe("runLogin: success", () => { expect(out.some((l) => l.includes("A code was sent to dev@example.com."))).toBe(true); }); - it("validates the code prompt input", async () => { + it("validates the code prompt input as letters for an email login", async () => { mockRouter({ "/login": [ () => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }), @@ -264,15 +264,55 @@ describe("runLogin: success", () => { "/otp/generate-login-email-otp": [() => json({ message: "sent" })], "/otp/verify-login-email-otp": [() => json({ token: "a", refreshToken: "r" })], }); - vi.mocked(text).mockResolvedValueOnce("dev@example.com").mockResolvedValueOnce("123456"); + vi.mocked(text).mockResolvedValueOnce("dev@example.com").mockResolvedValueOnce("ABCDEF"); + + await runLogin([]); + + const codeCall = vi.mocked(text).mock.calls[1][0] as { + placeholder: string; + validate: (v: string) => string | undefined; + }; + expect(codeCall.placeholder).toBe("ABCDEF"); + expect(codeCall.validate("ABCDEF")).toBeUndefined(); + expect(codeCall.validate("abcdef")).toBeUndefined(); + expect(codeCall.validate("123456")).toMatch(/6-letter code/); + }); + + it("uppercases a lowercase email code before verifying", async () => { + const calls = mockRouter({ + "/login": [ + () => json({ token: "e1", identifierType: "email", loginMethods: ["email_otp"] }), + ], + "/otp/generate-login-email-otp": [() => json({ message: "sent" })], + "/otp/verify-login-email-otp": [() => json({ token: "a", refreshToken: "r" })], + }); + vi.mocked(text).mockResolvedValueOnce("dev@example.com").mockResolvedValueOnce("abcdef"); + + await runLogin([]); + + const verify = calls.find((c) => c.url.endsWith("/otp/verify-login-email-otp"))!; + expect(verify.init.body).toBe(JSON.stringify({ verificationToken: "ABCDEF" })); + }); + + it("validates the code prompt input as digits for a phone login", async () => { + mockRouter({ + "/login": [ + () => json({ token: "e1", identifierType: "phone", loginMethods: ["phone_otp"] }), + ], + "/otp/generate-login-phone-otp": [() => json({ message: "sent" })], + "/otp/verify-login-phone-otp": [() => json({ token: "a", refreshToken: "r" })], + }); + vi.mocked(text).mockResolvedValueOnce("+15555550100").mockResolvedValueOnce("123456"); await runLogin([]); const codeCall = vi.mocked(text).mock.calls[1][0] as { + placeholder: string; validate: (v: string) => string | undefined; }; + expect(codeCall.placeholder).toBe("123456"); expect(codeCall.validate("123456")).toBeUndefined(); - expect(codeCall.validate("abc")).toMatch(/numeric code/); + expect(codeCall.validate("ABCDEF")).toMatch(/numeric code/); }); it("validates the identifier prompt input", async () => { diff --git a/src/commands/login.ts b/src/commands/login.ts index ad14802..1068571 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -41,17 +41,26 @@ export async function runLogin(args: string[]): Promise { const result = await completeLogin({ instanceUrl: profile.instanceUrl, identifier, - getCode: async ({ resent }) => { + getCode: async ({ resent, channel }) => { + const email = channel === "email"; const answer = await text({ message: resent ? "Enter the new code" : "Enter the code we sent you", - placeholder: "123456", - validate: (value) => - /^\d{4,8}$/.test((value ?? "").trim()) + placeholder: email ? "ABCDEF" : "123456", + validate: (value) => { + const code = (value ?? "").trim(); + if (email) { + return /^[A-Za-z]{6}$/.test(code) + ? undefined + : "Enter the 6-letter code from the email."; + } + return /^\d{4,8}$/.test(code) ? undefined - : "Enter the numeric code from the message", + : "Enter the numeric code from the message."; + }, }); if (isCancel(answer)) return null; - return (answer as string).trim(); + const code = (answer as string).trim(); + return email ? code.toUpperCase() : code; }, notify: (event) => { switch (event.type) { diff --git a/src/core/loginFlow.test.ts b/src/core/loginFlow.test.ts index 39ad6af..5c93f58 100644 --- a/src/core/loginFlow.test.ts +++ b/src/core/loginFlow.test.ts @@ -90,6 +90,26 @@ describe("completeLogin", () => { expect(verify.init.body).toBe(JSON.stringify({ verificationToken: "123456" })); }); + it("passes the channel to getCode so the prompt can validate accordingly", async () => { + mockRouter({ + "/login": [() => json({ token: "e1", identifierType: "phone" })], + "/otp/generate-login-phone-otp": [() => json({ message: "sent" })], + "/otp/verify-login-phone-otp": [() => json({ token: "a", refreshToken: "r" })], + }); + + let seenChannel: string | undefined; + await completeLogin({ + instanceUrl: INSTANCE, + identifier: "+15555550100", + getCode: async ({ channel }) => { + seenChannel = channel; + return "123456"; + }, + }); + + expect(seenChannel).toBe("phone"); + }); + it("retries a rejected code, then succeeds", async () => { mockRouter({ "/login": [ diff --git a/src/core/loginFlow.ts b/src/core/loginFlow.ts index 4aab524..c0c358d 100644 --- a/src/core/loginFlow.ts +++ b/src/core/loginFlow.ts @@ -32,7 +32,11 @@ export interface CompleteLoginOptions { identifier: string; maxAttempts?: number; now?: () => number; - getCode: (ctx: { attempt: number; resent: boolean }) => Promise; + getCode: (ctx: { + attempt: number; + resent: boolean; + channel: LoginChannel; + }) => Promise; notify?: (event: LoginEvent) => void; } @@ -204,7 +208,7 @@ export async function completeLogin( let attempt = 0; let resent = false; while (attempt < maxAttempts) { - const code = await opts.getCode({ attempt: attempt + 1, resent }); + const code = await opts.getCode({ attempt: attempt + 1, resent, channel }); resent = false; if (code === null) return null;