fix errors and onboarding problems (#242)

This commit is contained in:
Ben Senescu 2026-06-05 15:22:32 -04:00 committed by GitHub
parent cc5d6bc632
commit 1495660631
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 160 additions and 119 deletions

View File

@ -4,6 +4,7 @@ import type {
PageRow, PageRow,
SortOrder, SortOrder,
} from "@/client/features/domain/types"; } from "@/client/features/domain/types";
import { isValidDomainHost } from "@/types/schemas/domain";
export function toSortMode(value: string | null): DomainSortMode | undefined { export function toSortMode(value: string | null): DomainSortMode | undefined {
if ( if (
@ -67,6 +68,7 @@ export function normalizeDomainTarget(input: string): string | null {
const hostname = parsed.hostname.toLowerCase(); const hostname = parsed.hostname.toLowerCase();
if (!hostname || !hostname.includes(".")) return null; if (!hostname || !hostname.includes(".")) return null;
if (!/^[a-z\d.-]+$/.test(hostname)) return null; if (!/^[a-z\d.-]+$/.test(hostname)) return null;
if (!isValidDomainHost(hostname)) return null;
const path = parsed.pathname === "/" ? "" : parsed.pathname; const path = parsed.pathname === "/" ? "" : parsed.pathname;
return `${hostname}${path}`; return `${hostname}${path}`;

View File

@ -23,6 +23,8 @@ const STANDARD_MESSAGES: Record<ErrorCode, string> = {
AI_SEARCH_BILLING_ISSUE: AI_SEARCH_BILLING_ISSUE:
"The connected DataForSEO account has a billing or balance issue.", "The connected DataForSEO account has a billing or balance issue.",
RATE_LIMITED: "Too many requests. Please wait and try again.", RATE_LIMITED: "Too many requests. Please wait and try again.",
UPSTREAM_UNAVAILABLE:
"The data provider is temporarily unavailable. Please retry in a moment.",
CONFLICT: "This request conflicts with existing data.", CONFLICT: "This request conflicts with existing data.",
INTERNAL_ERROR: INTERNAL_ERROR:
"An unexpected error occurred. Please check server logs and try again.", "An unexpected error occurred. Please check server logs and try again.",

View File

@ -1,7 +1,6 @@
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { Link, createFileRoute } from "@tanstack/react-router"; import { Link, createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner";
import { import {
AuthPageCard, AuthPageCard,
AuthMethodChooser, AuthMethodChooser,
@ -11,7 +10,7 @@ import {
import { getFieldError, getFormError } from "@/client/lib/forms"; import { getFieldError, getFormError } from "@/client/lib/forms";
import { captureClientEvent } from "@/client/lib/posthog"; import { captureClientEvent } from "@/client/lib/posthog";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { getSignInSearch } from "@/lib/auth-redirect"; import { getSignInSearch, getVerifyEmailSearch } from "@/lib/auth-redirect";
import { z } from "zod"; import { z } from "zod";
const signInSchema = z.object({ const signInSchema = z.object({
@ -26,14 +25,11 @@ export const Route = createFileRoute("/_auth/sign-in")({
function SignInPage() { function SignInPage() {
const search = Route.useSearch(); const search = Route.useSearch();
const navigate = useNavigate();
const { redirectTo, oauthQuery, isHostedMode } = useAuthPageState( const { redirectTo, oauthQuery, isHostedMode } = useAuthPageState(
search.redirect, search.redirect,
); );
const authCallbackURL = redirectTo; const authCallbackURL = redirectTo;
const [verificationEmail, setVerificationEmail] = useState<string | null>(
null,
);
const [isSendingVerification, setIsSendingVerification] = useState(false);
const [showEmailForm, setShowEmailForm] = useState(false); const [showEmailForm, setShowEmailForm] = useState(false);
const [isStartingGoogle, setIsStartingGoogle] = useState(false); const [isStartingGoogle, setIsStartingGoogle] = useState(false);
const [socialError, setSocialError] = useState<string | null>(null); const [socialError, setSocialError] = useState<string | null>(null);
@ -52,7 +48,6 @@ function SignInPage() {
captureClientEvent("auth:sign_in_submit", { captureClientEvent("auth:sign_in_submit", {
redirect_to: redirectTo, redirect_to: redirectTo,
}); });
setVerificationEmail(null);
const result = await authClient.signIn.email({ const result = await authClient.signIn.email({
email, email,
@ -72,12 +67,12 @@ function SignInPage() {
captureClientEvent("auth:sign_in_block_unverified", { captureClientEvent("auth:sign_in_block_unverified", {
redirect_to: redirectTo, redirect_to: redirectTo,
}); });
setVerificationEmail(email); // Email not verified yet: send them to the verification page (which
formApi.setErrorMap({ // shows "check your inbox" + resend) instead of leaving them on a
onSubmit: { // sign-in form that will keep rejecting them.
form: "Please confirm your email before signing in.", void navigate({
fields: {}, to: "/verify-email",
}, search: getVerifyEmailSearch(email, redirectTo),
}); });
return; return;
} }
@ -99,42 +94,6 @@ function SignInPage() {
}, },
}); });
async function handleResendVerification() {
if (!verificationEmail) {
return;
}
setIsSendingVerification(true);
try {
const verificationCallbackURL = new URL(
"/verify-email",
window.location.origin,
);
if (authCallbackURL !== "/") {
verificationCallbackURL.searchParams.set("redirect", authCallbackURL);
}
const result = await authClient.sendVerificationEmail({
email: verificationEmail,
callbackURL: verificationCallbackURL.toString(),
});
if (result.error) {
toast.error(result.error.message || "We couldn't send another email.");
return;
}
captureClientEvent("auth:verification_resend");
toast.success("A new email is on the way.");
} catch {
toast.error(
"We couldn't send another email right now. Please try again.",
);
} finally {
setIsSendingVerification(false);
}
}
async function handleContinueWithGoogle() { async function handleContinueWithGoogle() {
setSocialError(null); setSocialError(null);
setIsStartingGoogle(true); setIsStartingGoogle(true);
@ -266,29 +225,6 @@ function SignInPage() {
}} }}
</form.Field> </form.Field>
{verificationEmail ? (
<div className="alert alert-warning items-start">
<div className="space-y-3">
<p className="text-sm">
Please check {verificationEmail} for a link to confirm your
email.
</p>
<button
type="button"
className="btn btn-sm btn-outline"
onClick={() => {
void handleResendVerification();
}}
disabled={isSendingVerification}
>
{isSendingVerification
? "Sending email..."
: "Send another email"}
</button>
</div>
</div>
) : null}
<form.Subscribe <form.Subscribe
selector={(state) => ({ selector={(state) => ({
submitError: state.errorMap.onSubmit, submitError: state.errorMap.onSubmit,

View File

@ -73,10 +73,12 @@ function getVerifyEmailPageCopy({
}; };
} }
if (isWaiting && email) { if (isWaiting) {
return { return {
title: "Verify your email", title: "Verify your email",
helperText: `Click the link we sent to ${email} to verify your email.`, helperText: email
? `Click the link we sent to ${email} to verify your email.`
: "Check your inbox for the link to verify your email.",
}; };
} }
@ -95,8 +97,8 @@ function getVerifyEmailPageCopy({
} }
return { return {
title: "Email confirmed", title: "Sign in to continue",
helperText: "Your email is confirmed. You can sign in now.", helperText: "Sign in to continue to your account.",
}; };
} }
@ -110,14 +112,18 @@ function VerifyEmailPage() {
const verificationIssueType = search.error const verificationIssueType = search.error
? verificationIssueSchema.parse(search.error) ? verificationIssueSchema.parse(search.error)
: null; : null;
const email = search.email; const email = search.email ?? session?.user?.email;
const isVerified = !!session?.user?.emailVerified;
const [isResending, setIsResending] = useState(false);
// A hosted user who still needs to verify (session resolved, not verified)
// must see the resend / "check your inbox" state — never a sign-in CTA, which
// the verification gate would immediately block (the email-verify trap).
const isWaiting = const isWaiting =
isHostedMode &&
!errorMessage && !errorMessage &&
!bypassEmailVerification && !bypassEmailVerification &&
!session?.user?.emailVerified && !isPending &&
!!email; !isVerified;
const [isResending, setIsResending] = useState(false);
const isVerified = !!session?.user?.emailVerified;
const pageCopy = getVerifyEmailPageCopy({ const pageCopy = getVerifyEmailPageCopy({
isHostedMode, isHostedMode,
errorMessage, errorMessage,
@ -222,16 +228,18 @@ function VerifyEmailPage() {
</Link> </Link>
</div> </div>
) : isWaiting ? ( ) : isWaiting ? (
<div className="space-y-4"> email ? (
<button <div className="space-y-4">
type="button" <button
className="btn btn-soft w-full" type="button"
onClick={() => void handleResend()} className="btn btn-soft w-full"
disabled={isResending} onClick={() => void handleResend()}
> disabled={isResending}
{isResending ? "Sending email..." : "Resend email"} >
</button> {isResending ? "Sending email..." : "Resend email"}
</div> </button>
</div>
) : null
) : isPending || isVerified ? ( ) : isPending || isVerified ? (
<div className="flex justify-center py-4"> <div className="flex justify-center py-4">
<span className="loading loading-spinner loading-md" /> <span className="loading loading-spinner loading-md" />

View File

@ -109,7 +109,7 @@ async function getSuggestedKeywords(
keywordDifficulty: number | null; keywordDifficulty: number | null;
}> }>
> { > {
const domain = input.domain.toLowerCase().trim(); const domain = normalizeDomainInput(input.domain, true);
const cacheKey = await buildCacheKey("domain:keyword-suggestions", { const cacheKey = await buildCacheKey("domain:keyword-suggestions", {
organizationId: billingCustomer.organizationId, organizationId: billingCustomer.organizationId,

View File

@ -8,11 +8,16 @@ import {
} from "dataforseo-client"; } from "dataforseo-client";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { getRequiredEnvValue } from "@/server/lib/runtime-env"; import { getRequiredEnvValue } from "@/server/lib/runtime-env";
import type { ErrorCode } from "@/shared/error-codes";
const API_BASE = "https://api.dataforseo.com"; const API_BASE = "https://api.dataforseo.com";
const MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH = 1600; const MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH = 1600;
// Safety ceiling on any live call (Lighthouse is the slowest, ~tens of seconds). // Safety ceiling on any live call (Lighthouse is the slowest, ~tens of seconds).
const DATAFORSEO_REQUEST_TIMEOUT_MS = 60_000; const DATAFORSEO_REQUEST_TIMEOUT_MS = 60_000;
// Retry idempotent reads on transient 5xx. Total attempts = retries + 1; the
// shared request-timeout signal still caps overall wall time.
const DATAFORSEO_MAX_RETRIES = 2;
const DATAFORSEO_RETRY_BACKOFF_MS = 250;
/** /**
* Translates a DataForSEO HTTP/task failure into a product-specific AppError * Translates a DataForSEO HTTP/task failure into a product-specific AppError
@ -63,32 +68,47 @@ function createAuthenticatedFetch(classify?: DataforseoErrorClassifier) {
const apiKey = await getRequiredEnvValue("DATAFORSEO_API_KEY"); const apiKey = await getRequiredEnvValue("DATAFORSEO_API_KEY");
const headers = new Headers(init?.headers); const headers = new Headers(init?.headers);
headers.set("Authorization", `Basic ${apiKey}`); headers.set("Authorization", `Basic ${apiKey}`);
// Resolve the signal once so retries share the overall request timeout
// rather than restarting a fresh 60s budget on each attempt.
const signal =
init?.signal ?? AbortSignal.timeout(DATAFORSEO_REQUEST_TIMEOUT_MS);
const response = await fetch(url, { for (let attempt = 0; ; attempt++) {
...init, const response = await fetch(url, { ...init, headers, signal });
headers, if (response.ok) return response;
signal:
init?.signal ?? AbortSignal.timeout(DATAFORSEO_REQUEST_TIMEOUT_MS),
});
if (response.ok) return response;
const rawText = await response.text(); // Transient upstream 5xx on an idempotent read -> back off and retry.
const path = formatDataforseoRequestPath(url); if (response.status >= 500 && attempt < DATAFORSEO_MAX_RETRIES) {
const classified = classify?.(response.status, rawText, path); await new Promise((resolve) =>
if (classified) throw classified; setTimeout(resolve, DATAFORSEO_RETRY_BACKOFF_MS * (attempt + 1)),
);
continue;
}
const error = new AppError( const rawText = await response.text();
response.status === 429 ? "RATE_LIMITED" : "INTERNAL_ERROR", const path = formatDataforseoRequestPath(url);
`DataForSEO HTTP ${response.status} on ${path}`, const classified = classify?.(response.status, rawText, path);
{ if (classified) throw classified;
provider: "dataforseo",
providerStatus: String(response.status), const code: ErrorCode =
providerPath: path, response.status >= 500
responseBody: formatDataforseoErrorPayload(rawText), ? "UPSTREAM_UNAVAILABLE"
}, : response.status === 429
); ? "RATE_LIMITED"
error.name = "DataForSEOHttpError"; : "INTERNAL_ERROR";
throw error; const error = new AppError(
code,
`DataForSEO HTTP ${response.status} on ${path}`,
{
provider: "dataforseo",
providerStatus: String(response.status),
providerPath: path,
responseBody: formatDataforseoErrorPayload(rawText),
},
);
error.name = "DataForSEOHttpError";
throw error;
}
}; };
} }

View File

@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { normalizeDomainInput } from "@/server/lib/domainUtils";
import { isValidDomainHost } from "@/types/schemas/domain";
describe("isValidDomainHost", () => {
it("accepts real registrable domains", () => {
expect(isValidDomainHost("example.com")).toBe(true);
expect(isValidDomainHost("sub.example.co.uk")).toBe(true);
expect(isValidDomainHost("openseo.so")).toBe(true);
});
it("rejects fake TLDs, IPs, and bare hosts", () => {
expect(isValidDomainHost("example.por")).toBe(false);
expect(isValidDomainHost("localhost")).toBe(false);
expect(isValidDomainHost("127.0.0.1")).toBe(false);
});
});
describe("normalizeDomainInput", () => {
it("normalizes a valid domain, stripping protocol/www/path", () => {
expect(
normalizeDomainInput("https://www.Example.com/path?q=1", false),
).toBe("example.com");
expect(normalizeDomainInput("blog.example.com", true)).toBe(
"blog.example.com",
);
});
it("rejects a fake TLD before it can reach DataForSEO", () => {
expect(() => normalizeDomainInput("victorgomez.por", false)).toThrowError(
/valid domain/i,
);
// Validation must also run on the includeSubdomains=true path.
expect(() => normalizeDomainInput("victorgomez.por", true)).toThrowError(
/valid domain/i,
);
});
it("rejects empty input", () => {
expect(() => normalizeDomainInput(" ", false)).toThrowError(/required/i);
});
});

View File

@ -1,5 +1,6 @@
import { getDomain } from "tldts"; import { getDomain } from "tldts";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { isValidDomainHost } from "@/types/schemas/domain";
export function toRelativePath(url: string | null | undefined): string | null { export function toRelativePath(url: string | null | undefined): string | null {
if (!url) return null; if (!url) return null;
@ -36,6 +37,15 @@ export function normalizeDomainInput(
throw new AppError("VALIDATION_ERROR", "Domain is invalid"); throw new AppError("VALIDATION_ERROR", "Domain is invalid");
} }
// Reject fake TLDs / non-registrable hosts (e.g. "example.por") before they
// reach DataForSEO and come back as an opaque "Invalid Field: 'target'".
if (!isValidDomainHost(host)) {
throw new AppError(
"VALIDATION_ERROR",
"Enter a valid domain like example.com",
);
}
if (includeSubdomains) { if (includeSubdomains) {
return host; return host;
} }

View File

@ -15,6 +15,7 @@ const ERROR_CODES = [
"AI_SEARCH_NOT_ENABLED", "AI_SEARCH_NOT_ENABLED",
"AI_SEARCH_BILLING_ISSUE", "AI_SEARCH_BILLING_ISSUE",
"RATE_LIMITED", "RATE_LIMITED",
"UPSTREAM_UNAVAILABLE",
"CONFLICT", "CONFLICT",
"INTERNAL_ERROR", "INTERNAL_ERROR",
] as const; ] as const;

View File

@ -1,3 +1,4 @@
import { parse as parseTld } from "tldts";
import { z } from "zod"; import { z } from "zod";
/** /**
@ -12,6 +13,19 @@ export function normalizeDomain(input: string): string {
return hostname.replace(/^www\./, ""); return hostname.replace(/^www\./, "");
} }
/**
* True when `host` resolves to a real registrable domain (public-suffix list),
* rejecting IPs and fake TLDs like `example.por` before they reach DataForSEO.
*/
export function isValidDomainHost(host: string): boolean {
const parsed = parseTld(host, { allowPrivateDomains: true });
return (
!parsed.isIp &&
!!parsed.publicSuffix &&
(parsed.isIcann === true || parsed.isPrivate === true)
);
}
/** Zod field: accepts a bare domain or full URL, outputs a clean hostname. */ /** Zod field: accepts a bare domain or full URL, outputs a clean hostname. */
export const domainField = z export const domainField = z
.string() .string()
@ -20,13 +34,19 @@ export const domainField = z
.transform((val, ctx) => { .transform((val, ctx) => {
try { try {
const hostname = normalizeDomain(val); const hostname = normalizeDomain(val);
if (!hostname.includes(".")) { if (!hostname.includes(".") || !isValidDomainHost(hostname)) {
ctx.addIssue({ code: "custom", message: "Invalid domain format" }); ctx.addIssue({
code: "custom",
message: "Enter a valid domain like example.com",
});
return z.NEVER; return z.NEVER;
} }
return hostname; return hostname;
} catch { } catch {
ctx.addIssue({ code: "custom", message: "Invalid domain format" }); ctx.addIssue({
code: "custom",
message: "Enter a valid domain like example.com",
});
return z.NEVER; return z.NEVER;
} }
}); });