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/login-email-otp-letters.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 43 additions & 3 deletions src/commands/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,23 +256,63 @@ 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"] }),
],
"/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 () => {
Expand Down
21 changes: 15 additions & 6 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,26 @@ export async function runLogin(args: string[]): Promise<void> {
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) {
Expand Down
20 changes: 20 additions & 0 deletions src/core/loginFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
8 changes: 6 additions & 2 deletions src/core/loginFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ export interface CompleteLoginOptions {
identifier: string;
maxAttempts?: number;
now?: () => number;
getCode: (ctx: { attempt: number; resent: boolean }) => Promise<string | null>;
getCode: (ctx: {
attempt: number;
resent: boolean;
channel: LoginChannel;
}) => Promise<string | null>;
notify?: (event: LoginEvent) => void;
}

Expand Down Expand Up @@ -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;

Expand Down
Loading