Re-add free plan as the floor; remove subscribe gate (#321)
This commit is contained in:
parent
819e33be07
commit
b5bda159ea
@ -1,11 +0,0 @@
|
|||||||
import { queryOptions } from "@tanstack/react-query";
|
|
||||||
import { getManagedAccessStatus } from "@/serverFunctions/billing";
|
|
||||||
|
|
||||||
export const MANAGED_ACCESS_QUERY_KEY = ["managedAccessStatus"];
|
|
||||||
|
|
||||||
export const managedAccessQueryOptions = () =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: MANAGED_ACCESS_QUERY_KEY,
|
|
||||||
queryFn: () => getManagedAccessStatus(),
|
|
||||||
staleTime: 30_000,
|
|
||||||
});
|
|
||||||
@ -38,8 +38,8 @@ export function getSubscribeRouteState(args: {
|
|||||||
return "redirectToApp" as const;
|
return "redirectToApp" as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grandfathered free-plan users landing here outside the upgrade flow
|
// Free-plan users landing here outside the upgrade flow belong in the app,
|
||||||
// belong in the app, not on the paywall.
|
// not on the paywall.
|
||||||
if (args.hasManagedAccess && !args.isUpgradeFlow) {
|
if (args.hasManagedAccess && !args.isUpgradeFlow) {
|
||||||
return "redirectToApp" as const;
|
return "redirectToApp" as const;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,70 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
|
||||||
import { useEffect } from "react";
|
|
||||||
import { managedAccessQueryOptions } from "@/client/features/billing/managed-access";
|
|
||||||
import { onboardingAnswersQueryOptions } from "@/client/features/onboarding/onboardingModel";
|
|
||||||
import { useSession } from "@/lib/auth-client";
|
|
||||||
import {
|
|
||||||
isEmailVerificationBypassed,
|
|
||||||
isHostedClientAuthMode,
|
|
||||||
} from "@/lib/auth-mode";
|
|
||||||
import { SUBSCRIBE_ROUTE } from "@/shared/billing";
|
|
||||||
|
|
||||||
// Account-management pages stay reachable without a subscription so gated
|
|
||||||
// users can change settings, read docs, or contact support (no dead ends).
|
|
||||||
const GATE_EXEMPT_PATH_PREFIXES = ["/settings", "/support", "/help"];
|
|
||||||
|
|
||||||
// Sends hosted users without managed access (no plan in Autumn) to the
|
|
||||||
// subscribe paywall. Runs only after onboarding completes so the onboarding
|
|
||||||
// redirect always wins first, and fails open on query errors — billing being
|
|
||||||
// down must not lock paying users out (spend is gated server-side anyway).
|
|
||||||
export function useSubscribeRedirect() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { data: session } = useSession();
|
|
||||||
const isHostedMode = isHostedClientAuthMode();
|
|
||||||
const isEmailVerified =
|
|
||||||
session?.user?.emailVerified === true || isEmailVerificationBypassed();
|
|
||||||
const isEligible =
|
|
||||||
isHostedMode && Boolean(session?.user?.id) && isEmailVerified;
|
|
||||||
|
|
||||||
const onboardingQuery = useQuery({
|
|
||||||
...onboardingAnswersQueryOptions(),
|
|
||||||
enabled: isEligible,
|
|
||||||
});
|
|
||||||
const hasCompletedOnboarding = Boolean(onboardingQuery.data?.completedAt);
|
|
||||||
|
|
||||||
const accessQuery = useQuery({
|
|
||||||
...managedAccessQueryOptions(),
|
|
||||||
enabled: isEligible && hasCompletedOnboarding,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isExemptPath = GATE_EXEMPT_PATH_PREFIXES.some((prefix) =>
|
|
||||||
pathname.startsWith(prefix),
|
|
||||||
);
|
|
||||||
const shouldRedirect =
|
|
||||||
isEligible &&
|
|
||||||
hasCompletedOnboarding &&
|
|
||||||
accessQuery.data?.hasManagedAccess === false &&
|
|
||||||
!isExemptPath;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!shouldRedirect) return;
|
|
||||||
void navigate({
|
|
||||||
to: SUBSCRIBE_ROUTE,
|
|
||||||
search: { redirect: pathname },
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
}, [navigate, pathname, shouldRedirect]);
|
|
||||||
|
|
||||||
// Hold rendering until we know whether the user may see the app, and while
|
|
||||||
// a redirect is imminent. This avoids flashing gated pages (which would
|
|
||||||
// fire their data queries and create default projects for users who never
|
|
||||||
// pass the paywall).
|
|
||||||
const isBlocking =
|
|
||||||
isEligible &&
|
|
||||||
hasCompletedOnboarding &&
|
|
||||||
(accessQuery.isLoading || shouldRedirect);
|
|
||||||
|
|
||||||
return { isBlocking };
|
|
||||||
}
|
|
||||||
@ -23,7 +23,6 @@ type PostSignupOnboardingProps = {
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onSkip: () => void;
|
onSkip: () => void;
|
||||||
onFinish: (mcpSetupIntent: "yes" | "no") => void;
|
onFinish: (mcpSetupIntent: "yes" | "no") => void;
|
||||||
onUpgradeAcknowledged: () => void;
|
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
accountMenu: ReactNode;
|
accountMenu: ReactNode;
|
||||||
};
|
};
|
||||||
@ -39,7 +38,6 @@ export function PostSignupOnboarding({
|
|||||||
onBack,
|
onBack,
|
||||||
onSkip,
|
onSkip,
|
||||||
onFinish,
|
onFinish,
|
||||||
onUpgradeAcknowledged,
|
|
||||||
isSaving,
|
isSaving,
|
||||||
accountMenu,
|
accountMenu,
|
||||||
}: PostSignupOnboardingProps) {
|
}: PostSignupOnboardingProps) {
|
||||||
@ -55,55 +53,6 @@ export function PostSignupOnboarding({
|
|||||||
const updateAnswers = (patch: Partial<OnboardingAnswers>) =>
|
const updateAnswers = (patch: Partial<OnboardingAnswers>) =>
|
||||||
onAnswersChange({ ...answers, ...patch });
|
onAnswersChange({ ...answers, ...patch });
|
||||||
|
|
||||||
// After a successful checkout the user lands on the GSC step with
|
|
||||||
// `?checkout=success`. Show a one-time "you're in" screen (same layout as the
|
|
||||||
// steps) and only reveal the actual GSC step once they continue, which drops
|
|
||||||
// the param.
|
|
||||||
const justUpgraded =
|
|
||||||
step === 3 &&
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
new URLSearchParams(window.location.search).get("checkout") === "success";
|
|
||||||
|
|
||||||
if (justUpgraded) {
|
|
||||||
return (
|
|
||||||
<div className="w-full max-w-md space-y-6">
|
|
||||||
{accountMenu}
|
|
||||||
|
|
||||||
<div className="text-center space-y-3">
|
|
||||||
<img
|
|
||||||
src="/transparent-logo.png"
|
|
||||||
alt="OpenSEO"
|
|
||||||
className="mx-auto size-10 rounded-lg"
|
|
||||||
/>
|
|
||||||
<h1 className="text-xl font-semibold">You’re in! 🎉</h1>
|
|
||||||
<p className="text-sm text-base-content/60">
|
|
||||||
Your subscription’s active.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-base-300 bg-base-100 p-5 shadow-sm">
|
|
||||||
<h2 className="text-lg font-semibold">
|
|
||||||
Finish setting up your account
|
|
||||||
</h2>
|
|
||||||
<p className="mt-1.5 text-sm leading-relaxed text-base-content/70">
|
|
||||||
Two quick steps left — connect Google Search Console, then set up
|
|
||||||
MCP for your agent.
|
|
||||||
</p>
|
|
||||||
<div className="mt-5 flex justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-soft"
|
|
||||||
onClick={onUpgradeAcknowledged}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
<ArrowRight className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-md space-y-6">
|
<div className="w-full max-w-md space-y-6">
|
||||||
{accountMenu}
|
{accountMenu}
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { env } from "cloudflare:workers";
|
import { env } from "cloudflare:workers";
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
|
import { APIError } from "better-auth/api";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
||||||
|
import { isDisposableEmailDomain } from "@/server/auth/disposable-email";
|
||||||
import * as d1Schema from "@/db/d1/schema";
|
import * as d1Schema from "@/db/d1/schema";
|
||||||
import { d1Db } from "@/db/d1/client";
|
import { d1Db } from "@/db/d1/client";
|
||||||
import { pgDb } from "@/db/pg/client";
|
import { pgDb } from "@/db/pg/client";
|
||||||
@ -84,6 +86,20 @@ function createAuth() {
|
|||||||
databaseHooks: {
|
databaseHooks: {
|
||||||
user: {
|
user: {
|
||||||
create: {
|
create: {
|
||||||
|
// Hosted only: keep cheap mass-signups off the free plan by rejecting
|
||||||
|
// throwaway-inbox domains before the user row is created. Self-hosted
|
||||||
|
// has no shared credit pool to protect, so it's left untouched.
|
||||||
|
before: async (user) => {
|
||||||
|
if (
|
||||||
|
isHostedAuthMode(env.AUTH_MODE) &&
|
||||||
|
isDisposableEmailDomain(user.email)
|
||||||
|
) {
|
||||||
|
throw new APIError("BAD_REQUEST", {
|
||||||
|
message: "Please sign up with a non-disposable email address.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { data: user };
|
||||||
|
},
|
||||||
after: async (user) => {
|
after: async (user) => {
|
||||||
await syncHostedSignupContact(user);
|
await syncHostedSignupContact(user);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,7 +2,6 @@ import { Outlet, createFileRoute } from "@tanstack/react-router";
|
|||||||
import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard";
|
import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard";
|
||||||
import { AuthenticatedAppLayout } from "@/client/layout/AppShell";
|
import { AuthenticatedAppLayout } from "@/client/layout/AppShell";
|
||||||
import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect";
|
import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect";
|
||||||
import { useSubscribeRedirect } from "@/client/features/billing/useSubscribeRedirect";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_app")({
|
export const Route = createFileRoute("/_app")({
|
||||||
component: AppRouteLayout,
|
component: AppRouteLayout,
|
||||||
@ -11,9 +10,8 @@ export const Route = createFileRoute("/_app")({
|
|||||||
function AppRouteLayout() {
|
function AppRouteLayout() {
|
||||||
const authGate = useHostedAuthRouteGuard();
|
const authGate = useHostedAuthRouteGuard();
|
||||||
useOnboardingRedirect();
|
useOnboardingRedirect();
|
||||||
const subscribeGate = useSubscribeRedirect();
|
|
||||||
|
|
||||||
if (!authGate.canRenderAuthenticatedContent || subscribeGate.isBlocking) {
|
if (!authGate.canRenderAuthenticatedContent) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,12 +10,9 @@ import {
|
|||||||
onboardingAnswersQueryOptions,
|
onboardingAnswersQueryOptions,
|
||||||
restoreOnboardingAnswers,
|
restoreOnboardingAnswers,
|
||||||
} from "@/client/features/onboarding/onboardingModel";
|
} from "@/client/features/onboarding/onboardingModel";
|
||||||
import { managedAccessQueryOptions } from "@/client/features/billing/managed-access";
|
|
||||||
import { captureClientEvent } from "@/client/lib/posthog";
|
import { captureClientEvent } from "@/client/lib/posthog";
|
||||||
import { queryClient } from "@/client/tanstack-db";
|
import { queryClient } from "@/client/tanstack-db";
|
||||||
import { useSession } from "@/lib/auth-client";
|
import { useSession } from "@/lib/auth-client";
|
||||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
|
||||||
import { SUBSCRIBE_ROUTE } from "@/shared/billing";
|
|
||||||
import { saveOnboardingAnswers } from "@/serverFunctions/onboarding";
|
import { saveOnboardingAnswers } from "@/serverFunctions/onboarding";
|
||||||
|
|
||||||
const ONBOARDING_EXISTING_USER_CUTOFF = "2026-05-27T00:00:00.000Z";
|
const ONBOARDING_EXISTING_USER_CUTOFF = "2026-05-27T00:00:00.000Z";
|
||||||
@ -25,17 +22,9 @@ const clampStep = (step: number) =>
|
|||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/onboarding/")({
|
export const Route = createFileRoute("/_authenticated/onboarding/")({
|
||||||
// Step lives in the URL so it survives refresh and works with back/forward.
|
// Step lives in the URL so it survives refresh and works with back/forward.
|
||||||
// The subscribe route appends `checkout=success` when it sends a just-paid
|
validateSearch: (search: Record<string, unknown>): { step: number } => {
|
||||||
// user back here; it triggers the one-time "You're in!" screen on the GSC
|
|
||||||
// step. Preserve it through validation so the router doesn't strip it.
|
|
||||||
validateSearch: (
|
|
||||||
search: Record<string, unknown>,
|
|
||||||
): { step: number; checkout?: string } => {
|
|
||||||
const raw = Number(search.step);
|
const raw = Number(search.step);
|
||||||
return {
|
return { step: Number.isFinite(raw) ? clampStep(raw) : 0 };
|
||||||
step: Number.isFinite(raw) ? clampStep(raw) : 0,
|
|
||||||
...(search.checkout === "success" ? { checkout: "success" } : {}),
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
// Send users who already finished onboarding home before rendering. Running
|
// Send users who already finished onboarding home before rendering. Running
|
||||||
// this in beforeLoad (not a component effect) means it can't race with the
|
// this in beforeLoad (not a component effect) means it can't race with the
|
||||||
@ -91,16 +80,6 @@ function OnboardingFlow({
|
|||||||
const { step } = Route.useSearch();
|
const { step } = Route.useSearch();
|
||||||
const [answers, setAnswers] = useState<OnboardingAnswers>(initialAnswers);
|
const [answers, setAnswers] = useState<OnboardingAnswers>(initialAnswers);
|
||||||
|
|
||||||
// Self-hosted has no paywall. Hosted users hit the subscribe gate after the
|
|
||||||
// three intro questions, before the paid GSC/MCP connect steps.
|
|
||||||
const isHostedMode = isHostedClientAuthMode();
|
|
||||||
const accessQuery = useQuery({
|
|
||||||
...managedAccessQueryOptions(),
|
|
||||||
enabled: isHostedMode,
|
|
||||||
});
|
|
||||||
const needsSubscription =
|
|
||||||
isHostedMode && accessQuery.data?.hasManagedAccess === false;
|
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (extra: {
|
mutationFn: (extra: {
|
||||||
mcpSetupIntent?: "yes" | "no";
|
mcpSetupIntent?: "yes" | "no";
|
||||||
@ -117,25 +96,6 @@ function OnboardingFlow({
|
|||||||
const goToStep = (next: number) =>
|
const goToStep = (next: number) =>
|
||||||
void navigate({ to: "/onboarding", search: { step: clampStep(next) } });
|
void navigate({ to: "/onboarding", search: { step: clampStep(next) } });
|
||||||
|
|
||||||
const advanceFromCurrentStep = () => {
|
|
||||||
const next = clampStep(step + 1);
|
|
||||||
// After the three intro questions, hosted users hit the subscribe paywall
|
|
||||||
// before the GSC/MCP connect steps. We return to step 3 (GSC) afterward;
|
|
||||||
// the subscribe route appends `checkout=success` once payment lands, which
|
|
||||||
// drives the one-time "You're in!" screen there.
|
|
||||||
// The strategy chat that used to sit here is parked for now; see
|
|
||||||
// /onboarding/chat (still routed) — we'll revisit it later.
|
|
||||||
if (step === 2 && needsSubscription) {
|
|
||||||
void navigate({
|
|
||||||
to: SUBSCRIBE_ROUTE,
|
|
||||||
search: { redirect: `/onboarding?step=${next}` },
|
|
||||||
replace: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
goToStep(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
if (step === 0) {
|
if (step === 0) {
|
||||||
captureClientEvent("onboarding:interests_selected", {
|
captureClientEvent("onboarding:interests_selected", {
|
||||||
@ -144,13 +104,13 @@ function OnboardingFlow({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
saveMutation.mutate({});
|
saveMutation.mutate({});
|
||||||
advanceFromCurrentStep();
|
goToStep(step + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSkip = () => {
|
const handleSkip = () => {
|
||||||
saveMutation.mutate({});
|
saveMutation.mutate({});
|
||||||
captureClientEvent("onboarding:step_skipped", { step });
|
captureClientEvent("onboarding:step_skipped", { step });
|
||||||
advanceFromCurrentStep();
|
goToStep(step + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFinish = async (mcpSetupIntent: "yes" | "no") => {
|
const handleFinish = async (mcpSetupIntent: "yes" | "no") => {
|
||||||
@ -191,9 +151,6 @@ function OnboardingFlow({
|
|||||||
onBack={() => goToStep(step - 1)}
|
onBack={() => goToStep(step - 1)}
|
||||||
onSkip={handleSkip}
|
onSkip={handleSkip}
|
||||||
onFinish={handleFinish}
|
onFinish={handleFinish}
|
||||||
onUpgradeAcknowledged={() =>
|
|
||||||
void navigate({ to: "/onboarding", search: { step }, replace: true })
|
|
||||||
}
|
|
||||||
isSaving={saveMutation.isPending}
|
isSaving={saveMutation.isPending}
|
||||||
accountMenu={<OnboardingAccountMenu email={email} />}
|
accountMenu={<OnboardingAccountMenu email={email} />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -10,9 +10,7 @@ import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
|||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
import { getSubscribeRouteState } from "@/client/features/billing/route-state";
|
import { getSubscribeRouteState } from "@/client/features/billing/route-state";
|
||||||
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
|
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
|
||||||
import { MANAGED_ACCESS_QUERY_KEY } from "@/client/features/billing/managed-access";
|
|
||||||
import { normalizeAuthRedirect } from "@/lib/auth-redirect";
|
import { normalizeAuthRedirect } from "@/lib/auth-redirect";
|
||||||
import { queryClient } from "@/client/tanstack-db";
|
|
||||||
import {
|
import {
|
||||||
AUTUMN_MANAGED_ACCESS_FEATURE_ID,
|
AUTUMN_MANAGED_ACCESS_FEATURE_ID,
|
||||||
AUTUMN_PAID_PLAN_ID,
|
AUTUMN_PAID_PLAN_ID,
|
||||||
@ -99,22 +97,11 @@ function SubscribePageContent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (subscribeRouteState === "redirectToApp") {
|
if (subscribeRouteState === "redirectToApp") {
|
||||||
// The app layouts gate on this query; make sure they see fresh access
|
|
||||||
// state instead of a cached "no access" that would bounce back here.
|
|
||||||
void queryClient.invalidateQueries({
|
|
||||||
queryKey: MANAGED_ACCESS_QUERY_KEY,
|
|
||||||
});
|
|
||||||
const destination = redirect ?? "/";
|
const destination = redirect ?? "/";
|
||||||
const [destinationPath, destinationQuery] = destination.split("?");
|
const [destinationPath, destinationQuery] = destination.split("?");
|
||||||
const destinationSearch: Record<string, string> = destinationQuery
|
const destinationSearch: Record<string, string> = destinationQuery
|
||||||
? Object.fromEntries(new URLSearchParams(destinationQuery))
|
? Object.fromEntries(new URLSearchParams(destinationQuery))
|
||||||
: {};
|
: {};
|
||||||
// Tell the destination a fresh checkout just landed (the onboarding GSC
|
|
||||||
// step uses this to show its one-time "You're in!" screen). Only set it
|
|
||||||
// when payment actually completed, not speculatively at redirect time.
|
|
||||||
if (checkoutCompleted) {
|
|
||||||
destinationSearch.checkout = "success";
|
|
||||||
}
|
|
||||||
const goToApp = () =>
|
const goToApp = () =>
|
||||||
void navigate({
|
void navigate({
|
||||||
to: destinationPath,
|
to: destinationPath,
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import { useEffect } from "react";
|
|||||||
import { setLastProjectId } from "@/client/lib/active-project";
|
import { setLastProjectId } from "@/client/lib/active-project";
|
||||||
import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard";
|
import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard";
|
||||||
import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner";
|
import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner";
|
||||||
import { useSubscribeRedirect } from "@/client/features/billing/useSubscribeRedirect";
|
|
||||||
import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect";
|
import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect";
|
||||||
import { getErrorCode } from "@/client/lib/error-messages";
|
import { getErrorCode } from "@/client/lib/error-messages";
|
||||||
import { AuthenticatedAppLayout } from "@/client/layout/AppShell";
|
import { AuthenticatedAppLayout } from "@/client/layout/AppShell";
|
||||||
@ -44,7 +43,6 @@ function ProjectLayout() {
|
|||||||
const { projectId } = Route.useParams();
|
const { projectId } = Route.useParams();
|
||||||
const authGate = useHostedAuthRouteGuard();
|
const authGate = useHostedAuthRouteGuard();
|
||||||
useOnboardingRedirect();
|
useOnboardingRedirect();
|
||||||
const subscribeGate = useSubscribeRedirect();
|
|
||||||
|
|
||||||
// Remember this as the last-visited project for the landing redirect.
|
// Remember this as the last-visited project for the landing redirect.
|
||||||
// Settings is excluded: editing another project's settings is
|
// Settings is excluded: editing another project's settings is
|
||||||
@ -58,7 +56,7 @@ function ProjectLayout() {
|
|||||||
setLastProjectId(projectId);
|
setLastProjectId(projectId);
|
||||||
}, [projectId, isSettingsPage]);
|
}, [projectId, isSettingsPage]);
|
||||||
|
|
||||||
if (!authGate.canRenderAuthenticatedContent || subscribeGate.isBlocking) {
|
if (!authGate.canRenderAuthenticatedContent) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
53
src/server/auth/disposable-email.ts
Normal file
53
src/server/auth/disposable-email.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
// A curated blocklist of the highest-volume disposable / throwaway email
|
||||||
|
// providers. The free plan grants real credit spend off nothing but a verified
|
||||||
|
// email, so the cheapest way to farm it is a temp-inbox service that can still
|
||||||
|
// receive the verification link. Blocking the busiest of those raises the cost
|
||||||
|
// of mass signups without touching legitimate users.
|
||||||
|
//
|
||||||
|
// This is deliberately a small, zero-dependency list — it is not exhaustive.
|
||||||
|
// For comprehensive coverage, swap this for the `disposable-email-domains`
|
||||||
|
// package or a captcha on signup (see the PR notes).
|
||||||
|
const DISPOSABLE_EMAIL_DOMAINS = new Set<string>([
|
||||||
|
"0clock.net",
|
||||||
|
"10minutemail.com",
|
||||||
|
"20minutemail.com",
|
||||||
|
"33mail.com",
|
||||||
|
"burnermail.io",
|
||||||
|
"dispostable.com",
|
||||||
|
"emailondeck.com",
|
||||||
|
"fakeinbox.com",
|
||||||
|
"getairmail.com",
|
||||||
|
"getnada.com",
|
||||||
|
"guerrillamail.com",
|
||||||
|
"guerrillamail.net",
|
||||||
|
"guerrillamail.org",
|
||||||
|
"inboxbear.com",
|
||||||
|
"inboxkitten.com",
|
||||||
|
"mail-temp.com",
|
||||||
|
"mailcatch.com",
|
||||||
|
"maildrop.cc",
|
||||||
|
"mailinator.com",
|
||||||
|
"mailnesia.com",
|
||||||
|
"moakt.com",
|
||||||
|
"mohmal.com",
|
||||||
|
"mytemp.email",
|
||||||
|
"sharklasers.com",
|
||||||
|
"spam4.me",
|
||||||
|
"temp-mail.io",
|
||||||
|
"temp-mail.org",
|
||||||
|
"tempmail.com",
|
||||||
|
"tempmail.dev",
|
||||||
|
"tempmailo.com",
|
||||||
|
"tempr.email",
|
||||||
|
"throwawaymail.com",
|
||||||
|
"trashmail.com",
|
||||||
|
"trashmail.de",
|
||||||
|
"yopmail.com",
|
||||||
|
"yopmail.fr",
|
||||||
|
"yopmail.net",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function isDisposableEmailDomain(email: string): boolean {
|
||||||
|
const domain = email.split("@").at(-1)?.trim().toLowerCase();
|
||||||
|
return domain !== undefined && DISPOSABLE_EMAIL_DOMAINS.has(domain);
|
||||||
|
}
|
||||||
@ -20,7 +20,8 @@ export const startAudit = createServerFn({ method: "POST" })
|
|||||||
.inputValidator((data: unknown) => startAuditSchema.parse(data))
|
.inputValidator((data: unknown) => startAuditSchema.parse(data))
|
||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
// The crawler runs on our Workers compute and isn't credit-metered, so
|
// The crawler runs on our Workers compute and isn't credit-metered, so
|
||||||
// gate it on plan access in hosted mode (grandfathered free plans pass).
|
// gate it on managed access in hosted mode. Free and paid plans both grant
|
||||||
|
// it; only customers with no Autumn product at all are turned away.
|
||||||
if (
|
if (
|
||||||
(await isHostedServerAuthMode()) &&
|
(await isHostedServerAuthMode()) &&
|
||||||
!(await customerHasManagedAccess(context.organizationId))
|
!(await customerHasManagedAccess(context.organizationId))
|
||||||
|
|||||||
@ -10,10 +10,6 @@ import {
|
|||||||
isHostedServerAuthMode,
|
isHostedServerAuthMode,
|
||||||
} from "@/server/lib/runtime-env";
|
} from "@/server/lib/runtime-env";
|
||||||
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
||||||
import {
|
|
||||||
customerHasManagedAccess,
|
|
||||||
getOrCreateOrganizationCustomer,
|
|
||||||
} from "@/server/billing/subscription";
|
|
||||||
|
|
||||||
const AUTUMN_EVENTS_LIST_URL = "https://api.useautumn.com/v1/events.list";
|
const AUTUMN_EVENTS_LIST_URL = "https://api.useautumn.com/v1/events.list";
|
||||||
const EVENT_PAGE_LIMIT = 1000;
|
const EVENT_PAGE_LIMIT = 1000;
|
||||||
@ -52,20 +48,6 @@ export type BillingUsageEvent = {
|
|||||||
properties: Record<string, z.infer<typeof billingUsagePropertySchema>>;
|
properties: Record<string, z.infer<typeof billingUsagePropertySchema>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Whether this organization may use the managed product at all. Both the
|
|
||||||
// legacy free plan (grandfathered users) and the paid base plan grant the
|
|
||||||
// feature; new customers get no default product and must subscribe.
|
|
||||||
export const getManagedAccessStatus = createServerFn({ method: "POST" })
|
|
||||||
.middleware(requireAuthenticatedContext)
|
|
||||||
.handler(async ({ context }) => {
|
|
||||||
if (!(await isHostedServerAuthMode())) {
|
|
||||||
return { hasManagedAccess: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const customer = await getOrCreateOrganizationCustomer(context);
|
|
||||||
return { hasManagedAccess: await customerHasManagedAccess(customer.id) };
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getBillingUsageEvents = createServerFn({ method: "POST" })
|
export const getBillingUsageEvents = createServerFn({ method: "POST" })
|
||||||
.middleware(requireAuthenticatedContext)
|
.middleware(requireAuthenticatedContext)
|
||||||
.inputValidator((data: unknown) => billingUsageRangeSchema.parse(data))
|
.inputValidator((data: unknown) => billingUsageRangeSchema.parse(data))
|
||||||
|
|||||||
@ -4,8 +4,9 @@ export const SUBSCRIBE_ROUTE = "/subscribe";
|
|||||||
export const AUTUMN_PAID_PLAN_ID = "base-plan";
|
export const AUTUMN_PAID_PLAN_ID = "base-plan";
|
||||||
export const AUTUMN_SEO_DATA_TOP_UP_PLAN_ID = "credit-top-up";
|
export const AUTUMN_SEO_DATA_TOP_UP_PLAN_ID = "credit-top-up";
|
||||||
export const AUTUMN_PAID_PLAN_FEATURE_ID = "paid_plan";
|
export const AUTUMN_PAID_PLAN_FEATURE_ID = "paid_plan";
|
||||||
// Granted by both the legacy free plan (grandfathered users) and the paid
|
// Granted by both the free plan (now the Autumn Default, so every non-paid
|
||||||
// base plan. New customers get no default product, so this is the paywall.
|
// user gets it) and the paid base plan. It's the floor for using the managed
|
||||||
|
// service at all — paid-only features gate on AUTUMN_PAID_PLAN_FEATURE_ID.
|
||||||
export const AUTUMN_MANAGED_ACCESS_FEATURE_ID = "managed_service_access";
|
export const AUTUMN_MANAGED_ACCESS_FEATURE_ID = "managed_service_access";
|
||||||
// The shared usage-credit pool. Both DataForSEO and onboarding-LLM spend deduct
|
// The shared usage-credit pool. Both DataForSEO and onboarding-LLM spend deduct
|
||||||
// from these (monthly usage_credits first, then rolled-over topup_credits).
|
// from these (monthly usage_credits first, then rolled-over topup_credits).
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user