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

View File

@ -23,6 +23,8 @@ const STANDARD_MESSAGES: Record<ErrorCode, string> = {
AI_SEARCH_BILLING_ISSUE:
"The connected DataForSEO account has a billing or balance issue.",
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.",
INTERNAL_ERROR:
"An unexpected error occurred. Please check server logs and try again.",

View File

@ -1,7 +1,6 @@
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 { toast } from "sonner";
import {
AuthPageCard,
AuthMethodChooser,
@ -11,7 +10,7 @@ import {
import { getFieldError, getFormError } from "@/client/lib/forms";
import { captureClientEvent } from "@/client/lib/posthog";
import { authClient } from "@/lib/auth-client";
import { getSignInSearch } from "@/lib/auth-redirect";
import { getSignInSearch, getVerifyEmailSearch } from "@/lib/auth-redirect";
import { z } from "zod";
const signInSchema = z.object({
@ -26,14 +25,11 @@ export const Route = createFileRoute("/_auth/sign-in")({
function SignInPage() {
const search = Route.useSearch();
const navigate = useNavigate();
const { redirectTo, oauthQuery, isHostedMode } = useAuthPageState(
search.redirect,
);
const authCallbackURL = redirectTo;
const [verificationEmail, setVerificationEmail] = useState<string | null>(
null,
);
const [isSendingVerification, setIsSendingVerification] = useState(false);
const [showEmailForm, setShowEmailForm] = useState(false);
const [isStartingGoogle, setIsStartingGoogle] = useState(false);
const [socialError, setSocialError] = useState<string | null>(null);
@ -52,7 +48,6 @@ function SignInPage() {
captureClientEvent("auth:sign_in_submit", {
redirect_to: redirectTo,
});
setVerificationEmail(null);
const result = await authClient.signIn.email({
email,
@ -72,12 +67,12 @@ function SignInPage() {
captureClientEvent("auth:sign_in_block_unverified", {
redirect_to: redirectTo,
});
setVerificationEmail(email);
formApi.setErrorMap({
onSubmit: {
form: "Please confirm your email before signing in.",
fields: {},
},
// Email not verified yet: send them to the verification page (which
// shows "check your inbox" + resend) instead of leaving them on a
// sign-in form that will keep rejecting them.
void navigate({
to: "/verify-email",
search: getVerifyEmailSearch(email, redirectTo),
});
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() {
setSocialError(null);
setIsStartingGoogle(true);
@ -266,29 +225,6 @@ function SignInPage() {
}}
</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
selector={(state) => ({
submitError: state.errorMap.onSubmit,

View File

@ -73,10 +73,12 @@ function getVerifyEmailPageCopy({
};
}
if (isWaiting && email) {
if (isWaiting) {
return {
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 {
title: "Email confirmed",
helperText: "Your email is confirmed. You can sign in now.",
title: "Sign in to continue",
helperText: "Sign in to continue to your account.",
};
}
@ -110,14 +112,18 @@ function VerifyEmailPage() {
const verificationIssueType = search.error
? verificationIssueSchema.parse(search.error)
: 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 =
isHostedMode &&
!errorMessage &&
!bypassEmailVerification &&
!session?.user?.emailVerified &&
!!email;
const [isResending, setIsResending] = useState(false);
const isVerified = !!session?.user?.emailVerified;
!isPending &&
!isVerified;
const pageCopy = getVerifyEmailPageCopy({
isHostedMode,
errorMessage,
@ -222,6 +228,7 @@ function VerifyEmailPage() {
</Link>
</div>
) : isWaiting ? (
email ? (
<div className="space-y-4">
<button
type="button"
@ -232,6 +239,7 @@ function VerifyEmailPage() {
{isResending ? "Sending email..." : "Resend email"}
</button>
</div>
) : null
) : isPending || isVerified ? (
<div className="flex justify-center py-4">
<span className="loading loading-spinner loading-md" />

View File

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

View File

@ -8,11 +8,16 @@ import {
} from "dataforseo-client";
import { AppError } from "@/server/lib/errors";
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
import type { ErrorCode } from "@/shared/error-codes";
const API_BASE = "https://api.dataforseo.com";
const MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH = 1600;
// Safety ceiling on any live call (Lighthouse is the slowest, ~tens of seconds).
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
@ -63,22 +68,36 @@ function createAuthenticatedFetch(classify?: DataforseoErrorClassifier) {
const apiKey = await getRequiredEnvValue("DATAFORSEO_API_KEY");
const headers = new Headers(init?.headers);
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, {
...init,
headers,
signal:
init?.signal ?? AbortSignal.timeout(DATAFORSEO_REQUEST_TIMEOUT_MS),
});
for (let attempt = 0; ; attempt++) {
const response = await fetch(url, { ...init, headers, signal });
if (response.ok) return response;
// Transient upstream 5xx on an idempotent read -> back off and retry.
if (response.status >= 500 && attempt < DATAFORSEO_MAX_RETRIES) {
await new Promise((resolve) =>
setTimeout(resolve, DATAFORSEO_RETRY_BACKOFF_MS * (attempt + 1)),
);
continue;
}
const rawText = await response.text();
const path = formatDataforseoRequestPath(url);
const classified = classify?.(response.status, rawText, path);
if (classified) throw classified;
const code: ErrorCode =
response.status >= 500
? "UPSTREAM_UNAVAILABLE"
: response.status === 429
? "RATE_LIMITED"
: "INTERNAL_ERROR";
const error = new AppError(
response.status === 429 ? "RATE_LIMITED" : "INTERNAL_ERROR",
code,
`DataForSEO HTTP ${response.status} on ${path}`,
{
provider: "dataforseo",
@ -89,6 +108,7 @@ function createAuthenticatedFetch(classify?: DataforseoErrorClassifier) {
);
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 { AppError } from "@/server/lib/errors";
import { isValidDomainHost } from "@/types/schemas/domain";
export function toRelativePath(url: string | null | undefined): string | null {
if (!url) return null;
@ -36,6 +37,15 @@ export function normalizeDomainInput(
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) {
return host;
}

View File

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

View File

@ -1,3 +1,4 @@
import { parse as parseTld } from "tldts";
import { z } from "zod";
/**
@ -12,6 +13,19 @@ export function normalizeDomain(input: string): string {
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. */
export const domainField = z
.string()
@ -20,13 +34,19 @@ export const domainField = z
.transform((val, ctx) => {
try {
const hostname = normalizeDomain(val);
if (!hostname.includes(".")) {
ctx.addIssue({ code: "custom", message: "Invalid domain format" });
if (!hostname.includes(".") || !isValidDomainHost(hostname)) {
ctx.addIssue({
code: "custom",
message: "Enter a valid domain like example.com",
});
return z.NEVER;
}
return hostname;
} catch {
ctx.addIssue({ code: "custom", message: "Invalid domain format" });
ctx.addIssue({
code: "custom",
message: "Enter a valid domain like example.com",
});
return z.NEVER;
}
});