Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions app/admin/[businessId]/open-customer/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server';

import { ADMIN_CUSTOMER_BUSINESS_COOKIE } from '@/lib/admin-customer-context';
import { resolveSafeAdminCustomerAppPath } from '@/lib/admin-customer-paths';
import { getAdminSession } from '@/lib/admin';
import { db } from '@/lib/db';

export async function GET(request: Request, { params }: { params: { businessId: string } }) {
const adminSession = await getAdminSession();
const requestUrl = new URL(request.url);

if (!adminSession?.userId) {
return NextResponse.redirect(new URL('/sign-in', requestUrl));
}

if (!adminSession.isAdmin) {
return NextResponse.redirect(new URL('/app', requestUrl));
}

const business = await db.business.findUnique({
where: { id: params.businessId },
select: { id: true },
});

if (!business) {
return NextResponse.redirect(new URL('/admin?error=Business%20not%20found.', requestUrl));
}

const nextPath = resolveSafeAdminCustomerAppPath(requestUrl.searchParams.get('path'));
const response = NextResponse.redirect(new URL(nextPath, requestUrl));

response.cookies.set(ADMIN_CUSTOMER_BUSINESS_COOKIE, business.id, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/app',
});

return response;
}
27 changes: 17 additions & 10 deletions app/admin/[businessId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
sendBusinessTestSmsAction,
setBusinessProvisioningStatusAction,
} from '@/app/admin/actions';
import { buildAdminCustomerOpenHref } from '@/lib/admin-customer-paths';
import {
buildAdminOnboardingConfidence,
canDeleteTestBusiness,
Expand Down Expand Up @@ -180,9 +181,9 @@ function getOperatorEventDetails(value: unknown) {

function buildOperatorEventRelatedHref(businessId: string, relatedEntityType: string | null, relatedEntityId: string | null) {
if (!relatedEntityType || !relatedEntityId) return null;
if (relatedEntityType === 'lead') return `/admin/${businessId}/workspace#recent-leads`;
if (relatedEntityType === 'lead') return buildAdminCustomerOpenHref(businessId, `/app/leads/${relatedEntityId}`);
if (relatedEntityType === 'message') return `/admin/${businessId}/workspace#recent-activity`;
if (relatedEntityType === 'call') return `/admin/${businessId}/workspace#call-flow-snapshot`;
if (relatedEntityType === 'call') return buildAdminCustomerOpenHref(businessId, '/app/call-flow');
return null;
}

Expand Down Expand Up @@ -495,12 +496,15 @@ export default async function AdminBusinessDetailPage({
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link className={buttonVariants({ variant: 'default' })} href={`/admin/${business.id}/workspace`}>
<Link className={buttonVariants({ variant: 'default' })} href={buildAdminCustomerOpenHref(business.id, '/app')}>
Open customer workspace
</Link>
<Link className={buttonVariants({ variant: 'outline' })} href={`/admin/${business.id}/workspace#recent-leads`}>
<Link className={buttonVariants({ variant: 'outline' })} href={buildAdminCustomerOpenHref(business.id, '/app/leads?view=attention')}>
Open customer leads
</Link>
<Link className={buttonVariants({ variant: 'outline' })} href={`/admin/${business.id}/workspace`}>
View support workspace snapshot
</Link>
<Link className={buttonVariants({ variant: 'outline' })} href="/admin">
Back to board
</Link>
Expand Down Expand Up @@ -670,22 +674,25 @@ export default async function AdminBusinessDetailPage({
<Card className="bg-card/90">
<CardHeader>
<CardTitle>Support mode shortcuts</CardTitle>
<CardDescription>Safe customer-side entry points without impersonation or tenant bleed.</CardDescription>
<CardDescription>Open the real customer pages for this business, or choose the snapshot view when you only need read-only context.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<Link className={buttonVariants({ variant: 'default', size: 'sm' })} href={`/admin/${business.id}/workspace`}>
<Link className={buttonVariants({ variant: 'default', size: 'sm' })} href={buildAdminCustomerOpenHref(business.id, '/app')}>
Open customer workspace
</Link>
<Link className={buttonVariants({ variant: 'outline', size: 'sm' })} href={`/admin/${business.id}/workspace#recent-leads`}>
<Link className={buttonVariants({ variant: 'outline', size: 'sm' })} href={buildAdminCustomerOpenHref(business.id, '/app/leads?view=attention')}>
Open customer leads
</Link>
<Link className={buttonVariants({ variant: 'outline', size: 'sm' })} href={`/admin/${business.id}/workspace#settings-snapshot`}>
<Link className={buttonVariants({ variant: 'outline', size: 'sm' })} href={buildAdminCustomerOpenHref(business.id, '/app/settings')}>
Open customer settings
</Link>
<Link className={buttonVariants({ variant: 'outline', size: 'sm' })} href={`/admin/${business.id}/workspace#call-flow-snapshot`}>
<Link className={buttonVariants({ variant: 'outline', size: 'sm' })} href={buildAdminCustomerOpenHref(business.id, '/app/call-flow')}>
Open customer call flow
</Link>
<Link className={buttonVariants({ variant: 'ghost', size: 'sm' })} href={`/admin/${business.id}/workspace`}>
View support workspace snapshot
</Link>
</div>

<form action={sendBusinessTestSmsAction} className="rounded-xl border bg-background/80 p-4">
Expand All @@ -711,7 +718,7 @@ export default async function AdminBusinessDetailPage({
</form>

<div className="rounded-xl border bg-background/80 p-4 text-sm text-muted-foreground">
Support mode stays read-only. Use it to inspect leads, settings, and call flow quickly without weakening business isolation.
Support workspace snapshots stay read-only. The buttons above open the real customer pages in an admin-scoped customer mode so you can act without impersonation.
</div>
</CardContent>
</Card>
Expand Down
28 changes: 22 additions & 6 deletions app/admin/[businessId]/workspace/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';

import { buildAdminCustomerOpenHref } from '@/lib/admin-customer-paths';
import { buildAdminNextStep } from '@/lib/admin-dashboard';
import { requireAdmin } from '@/lib/admin';
import { getAdminOwnerState } from '@/lib/admin-provisioning';
Expand Down Expand Up @@ -79,7 +80,7 @@ export default async function AdminBusinessWorkspacePage({ params }: { params: {
<div>
<h1 className="text-3xl font-semibold tracking-tight">{business.name} support mode workspace</h1>
<p className="text-sm text-muted-foreground">
Read-only customer context so the founder can inspect leads, settings, and call flow without impersonation.
Read-only customer snapshot for fast inspection. Use the customer-mode buttons below when you need the real editable customer pages.
</p>
</div>
<div className="flex flex-wrap gap-2">
Expand All @@ -96,7 +97,7 @@ export default async function AdminBusinessWorkspacePage({ params }: { params: {
<Card className="border-primary/20 bg-primary/5">
<CardHeader>
<CardTitle>Support snapshot</CardTitle>
<CardDescription>Immediate health signal plus quick jumps into the customer context that matter most.</CardDescription>
<CardDescription>Immediate health signal plus clear separation between real customer pages and read-only snapshot sections.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
Expand All @@ -122,17 +123,32 @@ export default async function AdminBusinessWorkspacePage({ params }: { params: {
</div>

<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<Link className={buttonVariants({ variant: 'outline', size: 'sm' })} href="#recent-leads">
<Link className={buttonVariants({ variant: 'default', size: 'sm' })} href={buildAdminCustomerOpenHref(business.id, '/app')}>
Open customer workspace
</Link>
<Link className={buttonVariants({ variant: 'outline', size: 'sm' })} href={buildAdminCustomerOpenHref(business.id, '/app/leads?view=attention')}>
Open customer leads
</Link>
<Link className={buttonVariants({ variant: 'outline', size: 'sm' })} href="#settings-snapshot">
<Link className={buttonVariants({ variant: 'outline', size: 'sm' })} href={buildAdminCustomerOpenHref(business.id, '/app/settings')}>
Open customer settings
</Link>
<Link className={buttonVariants({ variant: 'outline', size: 'sm' })} href="#call-flow-snapshot">
<Link className={buttonVariants({ variant: 'outline', size: 'sm' })} href={buildAdminCustomerOpenHref(business.id, '/app/call-flow')}>
Open customer call flow
</Link>
</div>

<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
<Link className={buttonVariants({ variant: 'ghost', size: 'sm' })} href="#recent-leads">
View leads snapshot
</Link>
<Link className={buttonVariants({ variant: 'ghost', size: 'sm' })} href="#settings-snapshot">
View settings snapshot
</Link>
<Link className={buttonVariants({ variant: 'ghost', size: 'sm' })} href="#call-flow-snapshot">
View call flow snapshot
</Link>
<Link className={buttonVariants({ variant: 'outline', size: 'sm' })} href="#recent-activity">
Open recent activity
View recent activity snapshot
</Link>
</div>
</CardContent>
Expand Down
31 changes: 31 additions & 0 deletions app/admin/exit-customer-mode/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';

import { ADMIN_CUSTOMER_BUSINESS_COOKIE } from '@/lib/admin-customer-context';
import { getAdminSession } from '@/lib/admin';

export async function GET(request: Request) {
const adminSession = await getAdminSession();
const requestUrl = new URL(request.url);

if (!adminSession?.userId) {
return NextResponse.redirect(new URL('/sign-in', requestUrl));
}

if (!adminSession.isAdmin) {
return NextResponse.redirect(new URL('/app', requestUrl));
}

const businessId = requestUrl.searchParams.get('businessId')?.trim();
const redirectPath = businessId ? `/admin/${businessId}` : '/admin';
const response = NextResponse.redirect(new URL(redirectPath, requestUrl));

response.cookies.set(ADMIN_CUSTOMER_BUSINESS_COOKIE, '', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/app',
maxAge: 0,
});

return response;
}
29 changes: 24 additions & 5 deletions app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
restoreBusinessAction,
sendBusinessTestSmsAction,
} from '@/app/admin/actions';
import { buildAdminCustomerOpenHref } from '@/lib/admin-customer-paths';
import {
adminBoardFilterOptions,
buildAdminBusinessPickerLabel,
Expand Down Expand Up @@ -566,12 +567,24 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor
<Link className={buttonVariants({ size: 'sm' })} href={`/admin/${selectedBusinessRow.business.id}`}>
Open business
</Link>
<Link className={buttonVariants({ variant: 'outline', size: 'sm' })} href={`/admin/${selectedBusinessRow.business.id}/workspace`}>
Open workspace
<Link
className={buttonVariants({ variant: 'outline', size: 'sm' })}
href={buildAdminCustomerOpenHref(selectedBusinessRow.business.id, '/app')}
>
Open customer workspace
</Link>
<Link
className={buttonVariants({ variant: 'outline', size: 'sm' })}
href={buildAdminCustomerOpenHref(selectedBusinessRow.business.id, '/app/settings')}
>
Open customer settings
</Link>
<Link className={buttonVariants({ variant: 'ghost', size: 'sm' })} href={`/admin/${selectedBusinessRow.business.id}#advanced`}>
Open full advanced controls
</Link>
<Link className={buttonVariants({ variant: 'ghost', size: 'sm' })} href={`/admin/${selectedBusinessRow.business.id}/workspace`}>
View support workspace snapshot
</Link>
</div>
</div>

Expand Down Expand Up @@ -723,12 +736,18 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor
<Link className={buttonVariants({ size: 'sm' })} href={`/admin/${business.id}`}>
Open business
</Link>
<Link className={buttonVariants({ variant: 'outline', size: 'sm' })} href={`/admin/${business.id}/workspace`}>
Open workspace
<Link className={buttonVariants({ variant: 'outline', size: 'sm' })} href={buildAdminCustomerOpenHref(business.id, '/app')}>
Open customer workspace
</Link>
<Link className={buttonVariants({ variant: 'outline', size: 'sm' })} href={`/admin/${business.id}/workspace#recent-leads`}>
<Link
className={buttonVariants({ variant: 'outline', size: 'sm' })}
href={buildAdminCustomerOpenHref(business.id, '/app/leads?view=attention')}
>
Open customer leads
</Link>
<Link className={buttonVariants({ variant: 'ghost', size: 'sm' })} href={`/admin/${business.id}/workspace`}>
View support workspace snapshot
</Link>
{!isBusinessArchived(business) ? (
<form action={provisionBusinessAction}>
<input type="hidden" name="businessId" value={business.id} />
Expand Down
46 changes: 36 additions & 10 deletions app/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { auth } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';
import Link from 'next/link';

import { AppNav } from '@/components/app-nav';
import { getAdminCustomerActingContext } from '@/lib/admin-customer-context';
import { buildAdminCustomerExitHref } from '@/lib/admin-customer-paths';
import { db } from '@/lib/db';
import { getPortfolioDemoBusiness, isPortfolioDemoMode } from '@/lib/portfolio-demo';
import { getCustomerSystemStatus } from '@/lib/system-status';
Expand All @@ -25,25 +28,48 @@ export default async function AppLayout({ children }: { children: React.ReactNod
);
}

const adminCustomerContext = await getAdminCustomerActingContext();
const { userId } = await auth();
if (!userId) {
redirect('/sign-in');
}

const business = await db.business.findUnique({ where: { ownerClerkId: userId } });
const business = adminCustomerContext
? adminCustomerContext.business
: await db.business.findUnique({ where: { ownerClerkId: userId } });
const successfulLeadCount = business
? await db.lead.count({ where: { businessId: business.id, OR: [{ ownerNotifiedAt: { not: null } }, { notifiedAt: { not: null } }] } })
: 0;
const systemStatus = business ? getCustomerSystemStatus(business, successfulLeadCount) : null;

return (
<div className="min-h-screen">
<AppNav
business={business}
systemStatusLabel={systemStatus?.label ?? 'Not live yet'}
systemStatusVariant={systemStatus?.badgeVariant ?? 'outline'}
/>
<main className="container py-8">{children}</main>
</div>
);
<div className="min-h-screen">
<AppNav
business={business}
systemStatusLabel={systemStatus?.label ?? 'Not live yet'}
systemStatusVariant={systemStatus?.badgeVariant ?? 'outline'}
/>
{adminCustomerContext && business ? (
<div className="border-b bg-primary/5">
<div className="container flex flex-col gap-3 py-3 text-sm lg:flex-row lg:items-center lg:justify-between">
<div>
<p className="font-medium">Admin customer mode</p>
<p className="text-muted-foreground">
You are using the real customer pages for <span className="font-medium text-foreground">{business.name}</span>.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link className="rounded-md border px-3 py-2 text-sm font-medium transition-colors hover:bg-background" href={`/admin/${business.id}`}>
Back to operator controls
</Link>
<Link className="rounded-md border px-3 py-2 text-sm font-medium transition-colors hover:bg-background" href={buildAdminCustomerExitHref(business.id)}>
Exit customer mode
</Link>
</div>
</div>
</div>
) : null}
<main className="container py-8">{children}</main>
</div>
);
}
10 changes: 3 additions & 7 deletions app/app/settings/actions.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
'use server';

import { auth, currentUser } from '@clerk/nextjs/server';
import { currentUser } from '@clerk/nextjs/server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

import { requireAdmin } from '@/lib/admin';
import { logAuditEvent } from '@/lib/audit-log';
import { getBusinessForOwnerClerkId } from '@/lib/business-access';
import { requireBusiness } from '@/lib/auth';
import { db } from '@/lib/db';
import { maskPhoneForAudit, normalizePhoneNumber, normalizePhoneNumberToE164 } from '@/lib/phone';
import { getTwilioBusinessClient } from '@/lib/twilio-client';
Expand All @@ -16,11 +16,7 @@ import { syncTwilioIncomingPhoneNumberWebhooks } from '@/lib/twilio';
import { businessSettingsSchema, businessTwilioAdminOverrideSchema, buyNumberSchema } from '@/lib/validators';

async function getBusinessForOwner() {
const { userId } = await auth();
if (!userId) redirect('/sign-in');
const business = await getBusinessForOwnerClerkId(userId);
if (!business) redirect('/app/onboarding');
return business;
return requireBusiness();
}

async function saveBusinessTwilioNumber(businessId: string, params: { phoneNumber: string | null; phoneNumberSid: string; syncedAt: Date }) {
Expand Down
Loading
Loading