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
54 changes: 54 additions & 0 deletions src/app/sign-in/_tests/phone-auth-form.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,58 @@ describe("phone OTP errors", () => {
expect(html).toContain("Vercel Connect");
expect(html).not.toContain('type="tel"');
});

it("explains and links the required first-time Messages flow", () => {
const html = renderToStaticMarkup(
createElement(PhoneAuthForm, {
callbackUrl: "/",
linqConfigured: true,
linqPhoneNumber: "+12025550123",
skipOtp: false,
})
);

expect(html).toContain("First time signing in?");
expect(html).toContain("Linq requires one message");
expect(html).toContain("Send any message");
expect(html).toContain("Return here and select Send code");
expect(html).toContain('href="sms:+12025550123"');
expect(html).toContain("Text Linq in Messages");
});

it("keeps the required flow visible when the number cannot be resolved", () => {
const html = renderToStaticMarkup(
createElement(PhoneAuthForm, {
callbackUrl: "/",
linqConfigured: true,
linqPhoneNumber: undefined,
skipOtp: false,
})
);

expect(html).toContain("First time signing in?");
expect(html).toContain("Find the Linq number in Vercel Connect");
expect(html).not.toContain("sms:");
const error = phoneOtpErrorMessage({
code: "LINQ_SENDING_LINE_UNAVAILABLE",
message:
"No Linq line is currently eligible. Complete the first-time sign-in steps above or review line health in Linq.",
});
expect(error).toContain("first-time sign-in steps above");
expect(error).not.toContain("button");
});

it("does not show Linq setup during local sign-in", () => {
const html = renderToStaticMarkup(
createElement(PhoneAuthForm, {
callbackUrl: "/",
linqConfigured: false,
linqPhoneNumber: undefined,
skipOtp: true,
})
);

expect(html).not.toContain("First time signing in?");
expect(html).toContain("Continue locally");
});
});
12 changes: 10 additions & 2 deletions src/app/sign-in/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { PhoneAuthForm } from "@/app/sign-in/phone-auth-form";
import { Logo } from "@/components/ui/logo";
import { env, localPhoneAuthBypassEnabled } from "@/env";
import { getAuthSession } from "@/auth/session";
import { readLinqOnboardingPhoneNumber } from "@/auth/linq";

export default async function SignInPage({
searchParams,
Expand All @@ -18,15 +19,22 @@ export default async function SignInPage({
requestedCallback?.startsWith("/") && !requestedCallback.startsWith("//")
? requestedCallback
: "/";
const linqConfigured = env.LINQ_CONNECTOR !== undefined;
const linqPhoneNumber =
localPhoneAuthBypassEnabled || !env.LINQ_CONNECTOR
? undefined
: (env.LINQ_PHONE_NUMBER ??
(await readLinqOnboardingPhoneNumber(env.LINQ_CONNECTOR)));

return (
<main className="flex min-h-svh items-center justify-center bg-background px-4 text-foreground">
<main className="flex min-h-svh items-center justify-center bg-background px-4 py-8 text-foreground">
<section className="w-full max-w-sm">
<Logo className="size-9" />
<h1 className="type-page-title mt-6">Sign in</h1>
<PhoneAuthForm
callbackUrl={callbackUrl}
linqConfigured={env.LINQ_CONNECTOR !== undefined}
linqConfigured={linqConfigured}
linqPhoneNumber={linqPhoneNumber}
skipOtp={localPhoneAuthBypassEnabled}
/>
</section>
Expand Down
100 changes: 75 additions & 25 deletions src/app/sign-in/phone-auth-form.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useState, type FormEvent } from "react";
import { MessageSquareIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
Expand All @@ -12,10 +13,12 @@ type AuthStep = "phone-number" | "verification-code";
export function PhoneAuthForm({
callbackUrl,
linqConfigured,
linqPhoneNumber,
skipOtp,
}: {
readonly callbackUrl: string;
readonly linqConfigured: boolean;
readonly linqPhoneNumber?: string;
readonly skipOtp: boolean;
}) {
const [error, setError] = useState<string>();
Expand Down Expand Up @@ -144,32 +147,79 @@ export function PhoneAuthForm({
}

return (
<form className="mt-6 space-y-4" onSubmit={submitDetails}>
<div className="space-y-2">
<Label htmlFor="phone-number">Phone number</Label>
<Input
autoComplete="tel"
id="phone-number"
onChange={(event) => setPhoneNumber(event.target.value.trim())}
placeholder="(202) 555-0123"
required
type="tel"
value={phoneNumber}
/>
<>
{!skipOtp ? <FirstTimeLinqSetup phoneNumber={linqPhoneNumber} /> : null}
<form
className={skipOtp ? "mt-6 space-y-4" : "mt-4 space-y-4"}
onSubmit={submitDetails}
>
<div className="space-y-2">
<Label htmlFor="phone-number">Phone number</Label>
<Input
autoComplete="tel"
id="phone-number"
onChange={(event) => setPhoneNumber(event.target.value.trim())}
placeholder="(202) 555-0123"
required
type="tel"
value={phoneNumber}
/>
</div>
{error ? (
<p className="type-supporting-body text-destructive">{error}</p>
) : null}
<Button className="w-full" disabled={loading} type="submit">
{loading
? skipOtp
? "Signing in…"
: "Sending…"
: skipOtp
? "Continue locally"
: "Send code"}
</Button>
</form>
</>
);
}

function FirstTimeLinqSetup({
phoneNumber,
}: {
readonly phoneNumber?: string;
}) {
return (
<section className="mt-6 space-y-3 rounded-lg border border-border/60 bg-muted/30 p-4">
<div className="space-y-1">
<h2 className="type-supporting-body font-medium">
First time signing in?
</h2>
<p className="type-caption text-muted-foreground">
Linq requires one message from your phone before it can send a sign-in
code.
</p>
</div>
{error ? (
<p className="type-supporting-body text-destructive">{error}</p>
) : null}
<Button className="w-full" disabled={loading} type="submit">
{loading
? skipOtp
? "Signing in…"
: "Sending…"
: skipOtp
? "Continue locally"
: "Send code"}
</Button>
</form>
<ol className="list-decimal space-y-1 pl-4 type-caption text-muted-foreground">
<li>Open Messages to the Linq number.</li>
<li>Send any message from the phone number you will enter below.</li>
<li>Return here and select Send code.</li>
</ol>
{phoneNumber ? (
<Button
className="w-full"
nativeButton={false}
render={<a href={`sms:${phoneNumber}`} />}
variant="outline"
>
<MessageSquareIcon />
Text Linq in Messages
</Button>
) : (
<p className="type-caption text-muted-foreground">
Find the Linq number in Vercel Connect or the Linq dashboard, text it
once, then return here.
</p>
)}
</section>
);
}

Expand Down
35 changes: 32 additions & 3 deletions src/auth/linq.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { getToken } from "@vercel/connect";
import { z } from "zod";
import { isE164PhoneNumber } from "./phone-number";

const LINQ_AVAILABLE_NUMBER_URL =
"https://api.linqapp.com/api/partner/v3/available_number";
const LINQ_MESSAGES_URL = "https://api.linqapp.com/api/partner/v3/messages";
const linqAvailableNumberSchema = z.object({
phone_number: z.string().refine(isE164PhoneNumber),
});
const linqErrorResponseSchema = z.object({
code: z.number().int().optional(),
error: z
Expand Down Expand Up @@ -57,18 +63,24 @@ export function linqOtpFailure(error: LinqDeliveryError) {
)
) {
return {
code: "LINQ_SENDING_LINE_NOT_VERIFIED",
code: "LINQ_SENDING_LINE_UNAVAILABLE",
message:
"This deployment's Linq phone number still needs its one-time verification. In Vercel Connect → Settings, follow the Phone Numbers verification instruction, then try again.",
"No Linq line is currently eligible to send a code. If this is a new line, complete the first-time sign-in steps above; otherwise review the line's health in Linq and try again.",
};
}

switch (error.code) {
case 2006:
return {
code: "LINQ_SENDING_LINE_NOT_AUTHORIZED",
message:
"Linq has not authorized a sending line for this connector. If this is a new line, complete the first-time sign-in steps above; otherwise confirm the connector's API token can access the active line in Linq.",
};
case 2008:
return {
code: "LINQ_RECIPIENT_NOT_VERIFIED",
message:
"This phone number must message your deployment's Linq phone number once before it can receive a sign-in code. Find the Linq phone number in Vercel Connect → Settings, send it any message from this phone, then try again.",
"Complete the first-time sign-in steps above: text the deployment's Linq number once from this phone, then request another code.",
};
case 2024:
return {
Expand All @@ -91,6 +103,23 @@ export function linqOtpFailure(error: LinqDeliveryError) {
}
}

export async function readLinqOnboardingPhoneNumber(connector: string) {
try {
const token = await getToken(connector, {
subject: { type: "app" },
});
const response = await fetch(LINQ_AVAILABLE_NUMBER_URL, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(3000),
});
if (!response.ok) return;
const body: unknown = await response.json().catch(() => undefined);
return linqAvailableNumberSchema.safeParse(body).data?.phone_number;
} catch {
return;
}
}

export async function sendLinqText({
connector,
idempotencyKey,
Expand Down
6 changes: 2 additions & 4 deletions src/auth/tests/auth-linq.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,16 +61,14 @@ describe("Linq phone authentication", () => {

const body = linqApiErrorSchema.parse(error.body);
expect(body).toMatchObject({
code: "LINQ_SENDING_LINE_NOT_VERIFIED",
code: "LINQ_SENDING_LINE_UNAVAILABLE",
linqError: {
code: 2015,
message: "no eligible sending line available",
status: 409,
trace_id: "trace-123",
},
});
expect(phoneOtpErrorMessage(body)).toContain(
"Phone Numbers verification instruction"
);
expect(phoneOtpErrorMessage(body)).toContain("line's health");
});
});
40 changes: 36 additions & 4 deletions src/auth/tests/linq.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
/* oxlint-disable vitest/require-mock-type-parameters -- The connector mock needs only the token operation exercised here. */
import { afterEach, describe, expect, it, vi } from "vitest";
import { LinqDeliveryError, linqOtpFailure, sendLinqText } from "@/auth/linq";
import {
LinqDeliveryError,
linqOtpFailure,
readLinqOnboardingPhoneNumber,
sendLinqText,
} from "@/auth/linq";

const mocks = vi.hoisted(() => ({ getToken: vi.fn() }));

Expand Down Expand Up @@ -38,6 +43,32 @@ describe("Linq delivery", () => {
expect(init?.method).toBe("POST");
});

it("retrieves the Linq number used for first-time onboarding", async () => {
mocks.getToken.mockResolvedValue("test-token");
const fetchMock = vi
.fn<typeof fetch>()
.mockResolvedValue(Response.json({ phone_number: "+12025550123" }));
vi.stubGlobal("fetch", fetchMock);

await expect(
readLinqOnboardingPhoneNumber("linq/open-instinct")
).resolves.toBe("+12025550123");
expect(mocks.getToken).toHaveBeenCalledWith("linq/open-instinct", {
subject: { type: "app" },
});
const [url, init] = fetchMock.mock.calls[0] ?? [];
expect(url).toBe("https://api.linqapp.com/api/partner/v3/available_number");
expect(init?.headers).toEqual({ Authorization: "Bearer test-token" });
});

it("fails soft when Linq cannot provide an onboarding number", async () => {
mocks.getToken.mockRejectedValue(new Error("connector unavailable"));

await expect(
readLinqOnboardingPhoneNumber("linq/open-instinct")
).resolves.toBeUndefined();
});

it("preserves diagnostics from Linq's current error envelope", async () => {
mocks.getToken.mockResolvedValue("test-token");
vi.stubGlobal(
Expand Down Expand Up @@ -120,7 +151,8 @@ describe("Linq delivery", () => {
});

it.each([
[2008, "LINQ_RECIPIENT_NOT_VERIFIED", "message your deployment"],
[2006, "LINQ_SENDING_LINE_NOT_AUTHORIZED", "API token"],
[2008, "LINQ_RECIPIENT_NOT_VERIFIED", "first-time sign-in steps"],
[2024, "LINQ_RECIPIENT_OPTED_OUT", "opted out"],
[2027, "LINQ_REPUTATION_BLOCKED", "messaging reputation"],
])("maps Linq code %i to actionable OTP copy", (code, expectedCode, copy) => {
Expand All @@ -141,8 +173,8 @@ describe("Linq delivery", () => {
})
);

expect(failure.code).toBe("LINQ_SENDING_LINE_NOT_VERIFIED");
expect(failure.message).toContain("Phone Numbers verification instruction");
expect(failure.code).toBe("LINQ_SENDING_LINE_UNAVAILABLE");
expect(failure.message).toContain("line's health");
});

it("does not mislabel unrelated Linq conflicts as verification failures", () => {
Expand Down