From 6abb5a0c2ab6cdb20a133be3b613d1df4a97209b Mon Sep 17 00:00:00 2001 From: DevCalebR Date: Mon, 20 Apr 2026 12:34:07 -0400 Subject: [PATCH] Fix admin support shortcut routing --- app/admin/[businessId]/open-customer/route.ts | 40 ++++++++++++++++ app/admin/[businessId]/page.tsx | 27 +++++++---- app/admin/[businessId]/workspace/page.tsx | 28 ++++++++--- app/admin/exit-customer-mode/route.ts | 31 +++++++++++++ app/admin/page.tsx | 29 ++++++++++-- app/app/layout.tsx | 46 +++++++++++++++---- app/app/settings/actions.ts | 10 ++-- lib/admin-customer-context.ts | 33 +++++++++++++ lib/admin-customer-paths.ts | 23 ++++++++++ lib/auth.ts | 11 +++++ tests/admin-customer-context.test.ts | 30 ++++++++++++ tests/admin-operator-routes.test.ts | 12 +++++ tests/tenant-isolation-wiring.test.ts | 2 +- 13 files changed, 283 insertions(+), 39 deletions(-) create mode 100644 app/admin/[businessId]/open-customer/route.ts create mode 100644 app/admin/exit-customer-mode/route.ts create mode 100644 lib/admin-customer-context.ts create mode 100644 lib/admin-customer-paths.ts create mode 100644 tests/admin-customer-context.test.ts diff --git a/app/admin/[businessId]/open-customer/route.ts b/app/admin/[businessId]/open-customer/route.ts new file mode 100644 index 0000000..8c0dd52 --- /dev/null +++ b/app/admin/[businessId]/open-customer/route.ts @@ -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; +} diff --git a/app/admin/[businessId]/page.tsx b/app/admin/[businessId]/page.tsx index d5d9ca9..711c62c 100644 --- a/app/admin/[businessId]/page.tsx +++ b/app/admin/[businessId]/page.tsx @@ -13,6 +13,7 @@ import { sendBusinessTestSmsAction, setBusinessProvisioningStatusAction, } from '@/app/admin/actions'; +import { buildAdminCustomerOpenHref } from '@/lib/admin-customer-paths'; import { buildAdminOnboardingConfidence, canDeleteTestBusiness, @@ -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; } @@ -495,12 +496,15 @@ export default async function AdminBusinessDetailPage({

- + Open customer workspace - + Open customer leads + + View support workspace snapshot + Back to board @@ -670,22 +674,25 @@ export default async function AdminBusinessDetailPage({ Support mode shortcuts - Safe customer-side entry points without impersonation or tenant bleed. + Open the real customer pages for this business, or choose the snapshot view when you only need read-only context.
- + Open customer workspace - + Open customer leads - + Open customer settings - + Open customer call flow + + View support workspace snapshot +
@@ -711,7 +718,7 @@ export default async function AdminBusinessDetailPage({
- 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.
diff --git a/app/admin/[businessId]/workspace/page.tsx b/app/admin/[businessId]/workspace/page.tsx index 0af13d7..cdacc0a 100644 --- a/app/admin/[businessId]/workspace/page.tsx +++ b/app/admin/[businessId]/workspace/page.tsx @@ -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'; @@ -79,7 +80,7 @@ export default async function AdminBusinessWorkspacePage({ params }: { params: {

{business.name} support mode workspace

- 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.

@@ -96,7 +97,7 @@ export default async function AdminBusinessWorkspacePage({ params }: { params: { Support snapshot - Immediate health signal plus quick jumps into the customer context that matter most. + Immediate health signal plus clear separation between real customer pages and read-only snapshot sections.
@@ -122,17 +123,32 @@ export default async function AdminBusinessWorkspacePage({ params }: { params: {
- + + Open customer workspace + + Open customer leads - + Open customer settings - + Open customer call flow +
+ +
+ + View leads snapshot + + + View settings snapshot + + + View call flow snapshot + - Open recent activity + View recent activity snapshot
diff --git a/app/admin/exit-customer-mode/route.ts b/app/admin/exit-customer-mode/route.ts new file mode 100644 index 0000000..42a41dc --- /dev/null +++ b/app/admin/exit-customer-mode/route.ts @@ -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; +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index 1cb042f..1ebe135 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -10,6 +10,7 @@ import { restoreBusinessAction, sendBusinessTestSmsAction, } from '@/app/admin/actions'; +import { buildAdminCustomerOpenHref } from '@/lib/admin-customer-paths'; import { adminBoardFilterOptions, buildAdminBusinessPickerLabel, @@ -566,12 +567,24 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor Open business - - Open workspace + + Open customer workspace + + + Open customer settings Open full advanced controls + + View support workspace snapshot +
@@ -723,12 +736,18 @@ export default async function AdminPage({ searchParams }: { searchParams?: Recor Open business - - Open workspace + + Open customer workspace - + Open customer leads + + View support workspace snapshot + {!isBusinessArchived(business) ? (
diff --git a/app/app/layout.tsx b/app/app/layout.tsx index f5977b3..30304bb 100644 --- a/app/app/layout.tsx +++ b/app/app/layout.tsx @@ -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'; @@ -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 ( -
- -
{children}
-
- ); +
+ + {adminCustomerContext && business ? ( +
+
+
+

Admin customer mode

+

+ You are using the real customer pages for {business.name}. +

+
+
+ + Back to operator controls + + + Exit customer mode + +
+
+
+ ) : null} +
{children}
+
+ ); } diff --git a/app/app/settings/actions.ts b/app/app/settings/actions.ts index 9ce5750..e37ebd9 100644 --- a/app/app/settings/actions.ts +++ b/app/app/settings/actions.ts @@ -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'; @@ -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 }) { diff --git a/lib/admin-customer-context.ts b/lib/admin-customer-context.ts new file mode 100644 index 0000000..579595f --- /dev/null +++ b/lib/admin-customer-context.ts @@ -0,0 +1,33 @@ +import { cookies } from 'next/headers'; + +import { getAdminSession } from '@/lib/admin'; +import { resolveSafeAdminCustomerAppPath } from '@/lib/admin-customer-paths'; +import { db } from '@/lib/db'; + +export const ADMIN_CUSTOMER_BUSINESS_COOKIE = 'cc_admin_customer_business'; + +export async function getAdminCustomerActingContext() { + const actingBusinessId = cookies().get(ADMIN_CUSTOMER_BUSINESS_COOKIE)?.value?.trim(); + if (!actingBusinessId) { + return null; + } + + const adminSession = await getAdminSession(); + if (!adminSession?.isAdmin) { + return null; + } + + const business = await db.business.findUnique({ + where: { id: actingBusinessId }, + }); + + if (!business) { + return null; + } + + return { + business, + adminUserId: adminSession.userId, + adminEmail: adminSession.email, + }; +} diff --git a/lib/admin-customer-paths.ts b/lib/admin-customer-paths.ts new file mode 100644 index 0000000..0ebbfde --- /dev/null +++ b/lib/admin-customer-paths.ts @@ -0,0 +1,23 @@ +export function resolveSafeAdminCustomerAppPath(value: string | null | undefined) { + const trimmed = value?.trim() || ''; + + if (!trimmed) return '/app'; + if (!trimmed.startsWith('/app')) return '/app'; + if (trimmed.startsWith('//')) return '/app'; + + return trimmed; +} + +export function buildAdminCustomerOpenHref(businessId: string, path = '/app') { + const safePath = resolveSafeAdminCustomerAppPath(path); + if (safePath === '/app') { + return `/admin/${businessId}/open-customer`; + } + + return `/admin/${businessId}/open-customer?path=${encodeURIComponent(safePath)}`; +} + +export function buildAdminCustomerExitHref(businessId?: string | null) { + if (!businessId) return '/admin/exit-customer-mode'; + return `/admin/exit-customer-mode?businessId=${encodeURIComponent(businessId)}`; +} diff --git a/lib/auth.ts b/lib/auth.ts index 7c66a18..572c932 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -1,6 +1,7 @@ import { auth } from '@clerk/nextjs/server'; import { redirect } from 'next/navigation'; +import { getAdminCustomerActingContext } from '@/lib/admin-customer-context'; import { getBusinessForOwnerClerkId } from '@/lib/business-access'; import { getPortfolioDemoAuth, getPortfolioDemoBusiness, isPortfolioDemoMode } from '@/lib/portfolio-demo'; @@ -21,6 +22,11 @@ export async function getCurrentBusiness() { return getPortfolioDemoBusiness(); } + const adminCustomerContext = await getAdminCustomerActingContext(); + if (adminCustomerContext) { + return adminCustomerContext.business; + } + const { userId } = await auth(); if (!userId) return null; @@ -32,6 +38,11 @@ export async function requireBusiness() { return getPortfolioDemoBusiness(); } + const adminCustomerContext = await getAdminCustomerActingContext(); + if (adminCustomerContext) { + return adminCustomerContext.business; + } + const { userId } = await requireAuth(); const business = await getBusinessForOwnerClerkId(userId); if (!business) { diff --git a/tests/admin-customer-context.test.ts b/tests/admin-customer-context.test.ts new file mode 100644 index 0000000..04d9812 --- /dev/null +++ b/tests/admin-customer-context.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildAdminCustomerExitHref, + buildAdminCustomerOpenHref, + resolveSafeAdminCustomerAppPath, +} from '../lib/admin-customer-paths.ts'; + +test('admin customer app paths stay scoped to /app routes', () => { + assert.equal(resolveSafeAdminCustomerAppPath('/app/settings'), '/app/settings'); + assert.equal(resolveSafeAdminCustomerAppPath('/app/leads?view=attention'), '/app/leads?view=attention'); + assert.equal(resolveSafeAdminCustomerAppPath(''), '/app'); + assert.equal(resolveSafeAdminCustomerAppPath('/admin'), '/app'); + assert.equal(resolveSafeAdminCustomerAppPath('//evil.example.com'), '/app'); +}); + +test('admin customer open and exit href builders stay predictable', () => { + assert.equal(buildAdminCustomerOpenHref('biz_123'), '/admin/biz_123/open-customer'); + assert.equal( + buildAdminCustomerOpenHref('biz_123', '/app/settings'), + '/admin/biz_123/open-customer?path=%2Fapp%2Fsettings' + ); + assert.equal( + buildAdminCustomerOpenHref('biz_123', '/app/leads?view=attention'), + '/admin/biz_123/open-customer?path=%2Fapp%2Fleads%3Fview%3Dattention' + ); + assert.equal(buildAdminCustomerExitHref('biz_123'), '/admin/exit-customer-mode?businessId=biz_123'); + assert.equal(buildAdminCustomerExitHref(), '/admin/exit-customer-mode'); +}); diff --git a/tests/admin-operator-routes.test.ts b/tests/admin-operator-routes.test.ts index 3e0a300..e7b9652 100644 --- a/tests/admin-operator-routes.test.ts +++ b/tests/admin-operator-routes.test.ts @@ -11,14 +11,17 @@ test('admin routes expose support workspace and safe lifecycle controls', () => const adminDetail = read('app/admin/[businessId]/page.tsx'); const supportWorkspace = read('app/admin/[businessId]/workspace/page.tsx'); const businessPicker = read('components/admin-business-picker.tsx'); + const appLayout = read('app/app/layout.tsx'); assert.match(adminHome, /Operator control panel/); assert.match(adminHome, /Fast onboard/); assert.match(adminHome, /Create workspace and start provisioning/); assert.match(adminHome, /Business triage board/); + assert.match(adminHome, /Open customer workspace/); assert.match(adminHome, /Delete demo\/test business permanently/); assert.match(adminHome, /Type business name to permanently delete/); assert.match(adminHome, /Open customer leads/); + assert.match(adminHome, /View support workspace snapshot/); assert.match(businessPicker, /Jump to business/); assert.match(businessPicker, /Clear selection/); assert.match(adminDetail, /Onboarding confidence/); @@ -32,8 +35,17 @@ test('admin routes expose support workspace and safe lifecycle controls', () => assert.match(adminDetail, /Invite owner by email/); assert.match(adminDetail, /Connect existing owner/); assert.match(adminDetail, /Send test SMS/); + assert.match(adminDetail, /Open customer settings/); + assert.match(adminDetail, /Open customer call flow/); + assert.match(adminDetail, /View support workspace snapshot/); assert.doesNotMatch(adminDetail, /Connect or invite owner/); assert.match(supportWorkspace, /support mode workspace/); + assert.match(supportWorkspace, /Open customer workspace/); + assert.match(supportWorkspace, /View leads snapshot/); + assert.match(supportWorkspace, /View settings snapshot/); + assert.match(supportWorkspace, /View call flow snapshot/); assert.match(supportWorkspace, /Customer settings snapshot/); assert.match(supportWorkspace, /Customer call flow snapshot/); + assert.match(appLayout, /Admin customer mode/); + assert.match(appLayout, /Exit customer mode/); }); diff --git a/tests/tenant-isolation-wiring.test.ts b/tests/tenant-isolation-wiring.test.ts index 5b20c18..df3b3f9 100644 --- a/tests/tenant-isolation-wiring.test.ts +++ b/tests/tenant-isolation-wiring.test.ts @@ -30,7 +30,7 @@ test('protected app surfaces use shared tenant-scoped access helpers', () => { assert.match(conversationsPage, /getConversationDetailForBusiness/); assert.match(leadActions, /updateLeadStatusForBusiness/); assert.match(settingsPage, /getBusinessNotificationSettingsForBusiness/); - assert.match(settingsActions, /getBusinessForOwnerClerkId/); + assert.match(settingsActions, /requireBusiness/); assert.match(billingPage, /getBillingUsageSnapshotForBusiness/); assert.match(recordingRoute, /getLeadRecordingForOwnerClerkId/); });