diff --git a/.env.example b/.env.example
index 37a5c27..be8a744 100644
--- a/.env.example
+++ b/.env.example
@@ -1,4 +1,5 @@
# App
+# Required in production. Use your deployed app origin, e.g. https://callbackcloser.com
NEXT_PUBLIC_APP_URL=
# Database (Postgres)
@@ -6,13 +7,16 @@ DATABASE_URL=
DIRECT_DATABASE_URL=
# Clerk
+# Required in production
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
+# Optional comma-separated admin emails allowed into /admin
ADMIN_EMAIL_ALLOWLIST=
# Stripe
+# Required in production
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
@@ -43,6 +47,7 @@ RESEND_API_KEY=
CALLBACKCLOSER_FROM_EMAIL=
# Optional public simulator
+# Keep these disabled in production unless the public simulator is intentionally enabled.
ENABLE_PUBLIC_MISSED_CALL_SIMULATOR=
SIMULATOR_BUSINESS_ID=
ENABLE_PUBLIC_SIMULATOR_REAL_SMS=
diff --git a/app/(auth)/sign-in/[[...sign-in]]/page.tsx b/app/(auth)/sign-in/[[...sign-in]]/page.tsx
index 4a6d964..24f988b 100644
--- a/app/(auth)/sign-in/[[...sign-in]]/page.tsx
+++ b/app/(auth)/sign-in/[[...sign-in]]/page.tsx
@@ -1,13 +1,42 @@
+import { auth } from '@clerk/nextjs/server';
import { SignIn } from '@clerk/nextjs';
+import { redirect } from 'next/navigation';
+import { getAdminSession } from '@/lib/admin';
+import { getBusinessForOwnerClerkId } from '@/lib/business-access';
import { DEFAULT_CLERK_AFTER_AUTH_URL, getClerkAuthUrls } from '@/lib/clerk-config';
+import { resolveSignedInAppDestination } from '@/lib/public-auth-routing';
+
+export default async function SignInPage() {
+ const { userId } = await auth();
+ if (userId) {
+ const [adminSession, business] = await Promise.all([
+ getAdminSession(),
+ getBusinessForOwnerClerkId(userId),
+ ]);
+
+ redirect(
+ resolveSignedInAppDestination({
+ isAdmin: Boolean(adminSession?.isAdmin),
+ hasBusiness: Boolean(business),
+ })
+ );
+ }
-export default function SignInPage() {
const { signInUrl, signUpUrl } = getClerkAuthUrls();
return (
-
-
+
+
+
Existing users
+
Sign in to your CallbackCloser workspace
+
+ Sign in is for existing owners and operators. If you need a new business workspace, create an account or start a free pilot from the public site instead.
+
+
+
+
+
);
}
diff --git a/app/(auth)/sign-up/[[...sign-up]]/page.tsx b/app/(auth)/sign-up/[[...sign-up]]/page.tsx
index 7bead95..08fd0af 100644
--- a/app/(auth)/sign-up/[[...sign-up]]/page.tsx
+++ b/app/(auth)/sign-up/[[...sign-up]]/page.tsx
@@ -1,13 +1,67 @@
+import { auth } from '@clerk/nextjs/server';
import { SignUp } from '@clerk/nextjs';
+import { redirect } from 'next/navigation';
+import { getAdminSession } from '@/lib/admin';
+import { getBusinessForOwnerClerkId } from '@/lib/business-access';
import { DEFAULT_CLERK_AFTER_AUTH_URL, getClerkAuthUrls } from '@/lib/clerk-config';
+import { resolveSignedInAppDestination } from '@/lib/public-auth-routing';
+
+function getIntentCopy(intent: string | undefined) {
+ if (intent === 'pilot') {
+ return {
+ label: 'Start Free Pilot',
+ title: 'Create your account and start pilot onboarding',
+ detail:
+ 'This path is for a business owner creating a new CallbackCloser account. If you are already signed in, CallbackCloser will send you to onboarding, your dashboard, or the admin new-business flow based on your role.',
+ };
+ }
+
+ return {
+ label: 'Create Account',
+ title: 'Create your CallbackCloser account',
+ detail:
+ 'Create a new owner account here. Founder-operated customer pilot setup is separate and stays inside the admin new-business flow.',
+ };
+}
+
+export default async function SignUpPage({
+ searchParams,
+}: {
+ searchParams?: Record;
+}) {
+ const { userId } = await auth();
+ if (userId) {
+ const [adminSession, business] = await Promise.all([
+ getAdminSession(),
+ getBusinessForOwnerClerkId(userId),
+ ]);
+
+ redirect(
+ resolveSignedInAppDestination({
+ isAdmin: Boolean(adminSession?.isAdmin),
+ hasBusiness: Boolean(business),
+ })
+ );
+ }
-export default function SignUpPage() {
const { signInUrl, signUpUrl } = getClerkAuthUrls();
+ const intent = typeof searchParams?.intent === 'string' ? searchParams.intent : undefined;
+ const copy = getIntentCopy(intent);
return (
-
-
+
+
+
{copy.label}
+
{copy.title}
+
{copy.detail}
+
+ Existing users should sign in. CallbackCloser operators setting up a customer pilot should use the admin new-business flow, not public signup.
+
+ Customer pilot setup starts here. Public signup is for business owners creating their own account; founder/operator setup for a real customer stays in this admin new-business flow.
+
+ ) : null}
diff --git a/app/api/twilio/provision-number/route.ts b/app/api/twilio/provision-number/route.ts
index c435a91..7220fa1 100644
--- a/app/api/twilio/provision-number/route.ts
+++ b/app/api/twilio/provision-number/route.ts
@@ -2,7 +2,9 @@ import { auth } from '@clerk/nextjs/server';
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
+import { getConfiguredAppBaseUrl } from '@/lib/env.server';
import { getCorrelationIdFromRequest, withCorrelationIdHeader } from '@/lib/observability';
+import { isAllowedRequestOrigin } from '@/lib/request-origin';
import { logTwilioError, logTwilioWarn } from '@/lib/twilio-logging';
import { getTwilioProvisioningBlockReason, provisionPhoneNumber } from '@/lib/twilio-provision';
@@ -35,6 +37,10 @@ export async function POST(request: Request) {
const correlationId = getCorrelationIdFromRequest(request);
const withCorrelation = (response: NextResponse) => withCorrelationIdHeader(response, correlationId);
+ if (process.env.NODE_ENV === 'production' && !isAllowedRequestOrigin(request, getConfiguredAppBaseUrl())) {
+ return withCorrelation(NextResponse.json({ error: 'Invalid request origin' }, { status: 403 }));
+ }
+
const { userId } = await auth();
if (!userId) {
return withCorrelation(NextResponse.json({ error: 'Unauthorized' }, { status: 401 }));
diff --git a/app/contact/page.tsx b/app/contact/page.tsx
index 3c5e94b..ba165d9 100644
--- a/app/contact/page.tsx
+++ b/app/contact/page.tsx
@@ -4,6 +4,7 @@ import { PublicSiteFooter } from '@/components/public-site-footer';
import { PublicSiteNav } from '@/components/public-site-nav';
import { buttonVariants } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { PUBLIC_CREATE_ACCOUNT_PATH, PUBLIC_START_FREE_PILOT_PATH } from '@/lib/public-auth-routing';
const outreachInputs = [
'Business name and service type',
@@ -45,11 +46,15 @@ export default function ContactPage() {
For SMS consent, STOP or HELP behavior, or public trust-page questions, email support and reference{' '}
callbackcloser.com so we can match the request to the live pilot setup.
+
Founder/operator customer pilot setup is separate from public signup and stays inside the admin new-business flow.
Get this live on your number fast, without changing how your team already works.
+
Demo only: fake business, fake callers, and no live customer or Twilio data.
@@ -221,7 +223,7 @@ export default function DemoPage() {
Want this on your business?
-
+
See it on your number
@@ -261,7 +263,7 @@ export default function DemoPage() {
-
+
See it on your number
diff --git a/app/page.tsx b/app/page.tsx
index c752e1e..770f652 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -5,6 +5,7 @@ import { PublicSiteNav } from '@/components/public-site-nav';
import { Badge } from '@/components/ui/badge';
import { buttonVariants } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { PUBLIC_CREATE_ACCOUNT_PATH, PUBLIC_START_FREE_PILOT_PATH } from '@/lib/public-auth-routing';
const roiPoints = [
{
@@ -145,14 +146,14 @@ export default function LandingPage() {
-
+
Start Free Pilot
-
- See Demo
+
+ Create Account
-
- Pricing
+
+ See Demo
@@ -162,6 +163,10 @@ export default function LandingPage() {
Less admin chasing
Close one extra job and this can pay for itself.
+
+ Start Free Pilot creates a new account or takes an existing user to the right next step automatically.
+ Founder-operated customer pilot setup stays separate in the admin new-business flow.
+
diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx
index d5a5291..ff85ca8 100644
--- a/app/pricing/page.tsx
+++ b/app/pricing/page.tsx
@@ -5,6 +5,7 @@ import { PublicSiteNav } from '@/components/public-site-nav';
import { Badge } from '@/components/ui/badge';
import { buttonVariants } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { PUBLIC_CREATE_ACCOUNT_PATH, PUBLIC_START_FREE_PILOT_PATH } from '@/lib/public-auth-routing';
const pricingPlans = [
{
@@ -66,7 +67,7 @@ export default function PricingPage() {
support@callbackcloser.com
- .
+ . Founder-run customer pilot setup is separate from public signup.
@@ -87,7 +88,7 @@ export default function PricingPage() {
Contact Sales
) : (
-
+
Start Free Pilot
)}
@@ -122,6 +123,9 @@ export default function PricingPage() {
STOP, START, and HELP handling remain part of the live messaging flow, and the consent page stays public.
Businesses remain responsible for lawful texting practices and consent requirements in their market.
+
+ Create account
+
Review SMS consent
diff --git a/app/simulator/actions.ts b/app/simulator/actions.ts
index 9d1a048..9a77213 100644
--- a/app/simulator/actions.ts
+++ b/app/simulator/actions.ts
@@ -38,7 +38,7 @@ export async function startSimulatorRunAction(formData: FormData) {
const transport = realSmsEnabled ? 'twilio' : 'simulated';
const notice = realSmsEnabled
? 'Simulator started. CallbackCloser should text the number you entered from the demo business texting line.'
- : 'Preview mode active. This simulator run updates the transcript and owner alerts on-page, but it does not text your phone until a real demo texting line is assigned and ENABLE_PUBLIC_SIMULATOR_REAL_SMS=true.';
+ : 'Preview mode active. This simulator run updates the transcript and owner alerts on-page, but it does not text your phone until the demo texting line is configured for real SMS delivery.';
const call = await db.call.create({
data: {
diff --git a/app/simulator/page.tsx b/app/simulator/page.tsx
index 98c7e99..0e788e7 100644
--- a/app/simulator/page.tsx
+++ b/app/simulator/page.tsx
@@ -11,6 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { formatDateTime, getLeadStatusBadgeVariant, leadReadinessLabels, leadStatusLabels } from '@/lib/lead-presenters';
+import { PUBLIC_CREATE_ACCOUNT_PATH, PUBLIC_START_FREE_PILOT_PATH } from '@/lib/public-auth-routing';
import { canSendRealSimulatorSms, getSimulatorBusiness, getSimulatorRun, isPlaceholderSimulatorNumber, isPublicSimulatorEnabled } from '@/lib/simulator';
export const metadata: Metadata = {
@@ -101,7 +102,7 @@ export default async function SimulatorPage({
Demo number unavailable
)}
-
+
Start Free Pilot
@@ -115,7 +116,7 @@ export default async function SimulatorPage({
? 'CallbackCloser will text the phone number you enter from the demo business texting line, then continue the rest of the flow in this page.'
: usingPlaceholderTextingLine
? 'This demo workspace is still using the safe placeholder texting line, so CallbackCloser will show the recovery text and owner alerts on this page instead of sending a real SMS.'
- : 'ENABLE_PUBLIC_SIMULATOR_REAL_SMS is still off, so CallbackCloser will show the recovery text and owner alerts on this page instead of sending a real SMS.'}
+ : 'Real SMS mode is not enabled for this environment yet, so CallbackCloser will show the recovery text and owner alerts on this page instead of sending a real SMS.'}
@@ -258,14 +259,14 @@ export default async function SimulatorPage({
)}