fix errors and onboarding problems (#242)
This commit is contained in:
parent
cc5d6bc632
commit
1495660631
@ -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}`;
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
42
src/server/lib/domainUtils.test.ts
Normal file
42
src/server/lib/domainUtils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ const ERROR_CODES = [
|
||||
"AI_SEARCH_NOT_ENABLED",
|
||||
"AI_SEARCH_BILLING_ISSUE",
|
||||
"RATE_LIMITED",
|
||||
"UPSTREAM_UNAVAILABLE",
|
||||
"CONFLICT",
|
||||
"INTERNAL_ERROR",
|
||||
] as const;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user