From 726760d7f613259329fefc7d9b6632990ad10fed Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Apr 2026 13:18:57 -0400 Subject: [PATCH 1/9] fix(onboarding): fix org creation timeout and improve error handling The initializeOrganization transaction runs 20+ DB operations (controls, policies, tasks, versions, requirement maps) and was hitting Prisma's default 5s timeout for users selecting multiple frameworks. - Set global transaction timeout to 30s across all 5 Prisma client instances - Clean up partially created org on failure to prevent orphans on retry - Surface actual error messages instead of generic "Failed to create organization" Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/prisma/client.ts | 7 ++++++- apps/app/prisma/client.ts | 7 ++++++- .../setup/actions/create-organization-minimal.ts | 12 ++++++++++++ .../src/app/(app)/setup/hooks/useOnboardingForm.ts | 6 +++--- apps/framework-editor/prisma/client.ts | 7 ++++++- apps/portal/prisma/client.ts | 7 ++++++- packages/db/src/client.ts | 7 ++++++- 7 files changed, 45 insertions(+), 8 deletions(-) diff --git a/apps/api/prisma/client.ts b/apps/api/prisma/client.ts index 573debfd25..21e833f75a 100644 --- a/apps/api/prisma/client.ts +++ b/apps/api/prisma/client.ts @@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient { // Strip sslmode from the connection string to avoid conflicts with the explicit ssl option const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; const adapter = new PrismaPg({ connectionString: url, ssl }); - return new PrismaClient({ adapter }); + return new PrismaClient({ + adapter, + transactionOptions: { + timeout: 30000, + }, + }); } export const db = globalForPrisma.prisma || createPrismaClient(); diff --git a/apps/app/prisma/client.ts b/apps/app/prisma/client.ts index d48e37720f..169de23539 100644 --- a/apps/app/prisma/client.ts +++ b/apps/app/prisma/client.ts @@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient { // Strip sslmode from the connection string to avoid conflicts with the explicit ssl option const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; const adapter = new PrismaPg({ connectionString: url, ssl }); - return new PrismaClient({ adapter }); + return new PrismaClient({ + adapter, + transactionOptions: { + timeout: 30000, + }, + }); } export const db = globalForPrisma.prisma || createPrismaClient(); diff --git a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts index 5ea1e258bb..9879ffcfb5 100644 --- a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts +++ b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts @@ -27,6 +27,8 @@ export const createOrganizationMinimal = authActionClientWithoutOrg }, }) .action(async ({ parsedInput, ctx }) => { + let createdOrgId: string | undefined; + try { const session = await auth.api.getSession({ headers: await headers(), @@ -83,6 +85,7 @@ export const createOrganizationMinimal = authActionClientWithoutOrg }); const orgId = newOrg.id; + createdOrgId = orgId; // Get the member that was created with the organization (the owner) const ownerMember = await db.member.findFirst({ @@ -139,6 +142,15 @@ export const createOrganizationMinimal = authActionClientWithoutOrg } catch (error) { console.error('Error during minimal organization creation:', error); + // Clean up partially created org to prevent orphans on retry + if (createdOrgId) { + try { + await db.organization.delete({ where: { id: createdOrgId } }); + } catch (cleanupError) { + console.error('Failed to clean up org after creation error:', cleanupError); + } + } + if (error instanceof Error) { return { success: false, diff --git a/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts b/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts index a91d04fa05..7cf07a47eb 100644 --- a/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts +++ b/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts @@ -130,13 +130,13 @@ export function useOnboardingForm({ // Clear answers after successful creation setSavedAnswers({}); } else { - toast.error('Failed to create organization'); + toast.error(data?.error || 'Failed to create organization'); setIsFinalizing(false); setIsOnboarding(false); } }, - onError: () => { - toast.error('Failed to create organization'); + onError: ({ error }) => { + toast.error(error.serverError || 'Failed to create organization'); setIsFinalizing(false); setIsOnboarding(false); }, diff --git a/apps/framework-editor/prisma/client.ts b/apps/framework-editor/prisma/client.ts index 573debfd25..21e833f75a 100644 --- a/apps/framework-editor/prisma/client.ts +++ b/apps/framework-editor/prisma/client.ts @@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient { // Strip sslmode from the connection string to avoid conflicts with the explicit ssl option const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; const adapter = new PrismaPg({ connectionString: url, ssl }); - return new PrismaClient({ adapter }); + return new PrismaClient({ + adapter, + transactionOptions: { + timeout: 30000, + }, + }); } export const db = globalForPrisma.prisma || createPrismaClient(); diff --git a/apps/portal/prisma/client.ts b/apps/portal/prisma/client.ts index d48e37720f..169de23539 100644 --- a/apps/portal/prisma/client.ts +++ b/apps/portal/prisma/client.ts @@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient { // Strip sslmode from the connection string to avoid conflicts with the explicit ssl option const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; const adapter = new PrismaPg({ connectionString: url, ssl }); - return new PrismaClient({ adapter }); + return new PrismaClient({ + adapter, + transactionOptions: { + timeout: 30000, + }, + }); } export const db = globalForPrisma.prisma || createPrismaClient(); diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 573debfd25..21e833f75a 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -19,7 +19,12 @@ function createPrismaClient(): PrismaClient { // Strip sslmode from the connection string to avoid conflicts with the explicit ssl option const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; const adapter = new PrismaPg({ connectionString: url, ssl }); - return new PrismaClient({ adapter }); + return new PrismaClient({ + adapter, + transactionOptions: { + timeout: 30000, + }, + }); } export const db = globalForPrisma.prisma || createPrismaClient(); From a9cb9c5615b8a1a24894164dc166767f07fadec1 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Apr 2026 13:34:37 -0400 Subject: [PATCH 2/9] fix(onboarding): don't delete org after session activation succeeds Address Bugbot review: if setActiveOrganization succeeded but a later step (revalidatePath) threw, the cleanup would delete a fully initialized org while the session still referenced it. Now cleanup is disabled after activation, and revalidatePath errors are caught separately since they are non-critical. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../actions/create-organization-minimal.ts | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts index 9879ffcfb5..35e9b9ef99 100644 --- a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts +++ b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts @@ -116,22 +116,28 @@ export const createOrganizationMinimal = authActionClientWithoutOrg }); } - // Set new org as active + // Set new org as active — after this point, the session references + // the org so we must NOT delete it on cleanup. await auth.api.setActiveOrganization({ headers: await headers(), body: { organizationId: orgId, }, }); - - // Revalidate paths - const headersList = await headers(); - let path = headersList.get('x-pathname') || headersList.get('referer') || ''; - path = path.replace(/\/[a-z]{2}\//, '/'); - - revalidatePath(path); - revalidatePath('/'); - revalidatePath('/setup'); + createdOrgId = undefined; // Org is fully initialized, disable cleanup + + // Revalidate paths (non-critical, don't let failures kill the flow) + try { + const headersList = await headers(); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); + + revalidatePath(path); + revalidatePath('/'); + revalidatePath('/setup'); + } catch (revalidateError) { + console.error('Non-critical: failed to revalidate paths:', revalidateError); + } // NO JOB TRIGGERS - that happens after payment in complete-onboarding @@ -142,7 +148,9 @@ export const createOrganizationMinimal = authActionClientWithoutOrg } catch (error) { console.error('Error during minimal organization creation:', error); - // Clean up partially created org to prevent orphans on retry + // Clean up partially created org to prevent orphans on retry. + // Only runs if the org was created but setActiveOrganization hasn't + // succeeded yet (createdOrgId is cleared after activation). if (createdOrgId) { try { await db.organization.delete({ where: { id: createdOrgId } }); From 8e53a10fb5375397da1d7bee58b5ab49fd65984b Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Apr 2026 13:46:29 -0400 Subject: [PATCH 3/9] fix(onboarding): disable Complete button while server action is running MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isOnboarding is true during the server action but wasn't used in the disabled prop — only isSubmitting (react-hook-form) was, which resets after the synchronous onSubmit handler. This allowed double-clicks to create duplicate orgs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/app/(app)/setup/components/OnboardingFormActions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx b/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx index 27b6190230..04d51c7aaf 100644 --- a/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx +++ b/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx @@ -82,7 +82,7 @@ export function OnboardingFormActions({ type="submit" form="onboarding-form" // Important: links to the form in OrganizationSetupForm.tsx className="flex items-center gap-2" - disabled={isSubmitting || !isCurrentStepValid} + disabled={isSubmitting || isOnboarding || !isCurrentStepValid} data-testid="setup-finish-button" > Date: Fri, 10 Apr 2026 14:02:16 -0400 Subject: [PATCH 4/9] feat(onboarding): add cancel button to abandon onboarding and return to previous org When users create an additional org via create-additional flow, they get trapped in the onboarding funnel with no way to go back. This adds a Cancel button (visible only when user has other completed orgs) to: - Pre-payment setup form: navigates back to root (no org to delete yet) - Upgrade page: deletes incomplete org, switches to previous org - Post-payment onboarding: deletes incomplete org, switches to previous org Includes confirmation step before deletion to prevent accidental cancels. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/app/(app)/onboarding/[orgId]/page.tsx | 11 +++ .../onboarding/actions/cancel-onboarding.ts | 85 +++++++++++++++++++ .../components/CancelOnboardingButton.tsx | 73 ++++++++++++++++ .../components/PostPaymentOnboarding.tsx | 7 ++ .../src/app/(app)/setup/[setupId]/page.tsx | 11 +++ .../components/OnboardingFormActions.tsx | 13 +++ .../components/OrganizationSetupForm.tsx | 3 + .../[orgId]/components/booking-step.tsx | 11 +++ .../src/app/(app)/upgrade/[orgId]/page.tsx | 17 +++- 9 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts create mode 100644 apps/app/src/app/(app)/onboarding/components/CancelOnboardingButton.tsx diff --git a/apps/app/src/app/(app)/onboarding/[orgId]/page.tsx b/apps/app/src/app/(app)/onboarding/[orgId]/page.tsx index bf9588ca2c..49a103261b 100644 --- a/apps/app/src/app/(app)/onboarding/[orgId]/page.tsx +++ b/apps/app/src/app/(app)/onboarding/[orgId]/page.tsx @@ -109,12 +109,23 @@ export default async function OnboardingPage({ params }: OnboardingPageProps) { }); } + // Check if user has other completed orgs (for cancel button) + const otherOrgCount = await db.member.count({ + where: { + userId: session.user.id, + organizationId: { not: orgId }, + deactivated: false, + organization: { onboardingCompleted: true, hasAccess: true }, + }, + }); + // We'll use a modified version that starts at step 3 return ( 0} /> ); } diff --git a/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts b/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts new file mode 100644 index 0000000000..8b98f95a7e --- /dev/null +++ b/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts @@ -0,0 +1,85 @@ +'use server'; + +import { authActionClientWithoutOrg } from '@/actions/safe-action'; +import { auth } from '@/utils/auth'; +import { db } from '@db/server'; +import { headers } from 'next/headers'; +import { z } from 'zod'; + +const cancelSchema = z.object({ + organizationId: z.string().min(1), +}); + +export const cancelOnboarding = authActionClientWithoutOrg + .inputSchema(cancelSchema) + .metadata({ + name: 'cancel-onboarding', + track: { + event: 'cancel-onboarding', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return { success: false, error: 'Not authorized.' }; + } + + // Verify the user owns this org + const member = await db.member.findFirst({ + where: { + userId: session.user.id, + organizationId: parsedInput.organizationId, + role: { contains: 'owner' }, + }, + }); + + if (!member) { + return { success: false, error: 'Only the owner can cancel onboarding.' }; + } + + // Find a fallback org to switch to (completed, with access) + const fallbackOrg = await db.member.findFirst({ + where: { + userId: session.user.id, + organizationId: { not: parsedInput.organizationId }, + deactivated: false, + organization: { + onboardingCompleted: true, + hasAccess: true, + }, + }, + select: { organizationId: true }, + orderBy: { createdAt: 'desc' }, + }); + + // Delete the incomplete org (cascade handles related records) + try { + await db.organization.delete({ + where: { id: parsedInput.organizationId }, + }); + } catch (error) { + console.error('Failed to delete organization:', error); + return { success: false, error: 'Failed to cancel onboarding.' }; + } + + // Switch to fallback org if available + if (fallbackOrg) { + try { + await auth.api.setActiveOrganization({ + headers: await headers(), + body: { organizationId: fallbackOrg.organizationId }, + }); + } catch (error) { + console.error('Failed to switch to fallback org:', error); + } + } + + return { + success: true, + fallbackOrgId: fallbackOrg?.organizationId ?? null, + }; + }); diff --git a/apps/app/src/app/(app)/onboarding/components/CancelOnboardingButton.tsx b/apps/app/src/app/(app)/onboarding/components/CancelOnboardingButton.tsx new file mode 100644 index 0000000000..12c6ed492a --- /dev/null +++ b/apps/app/src/app/(app)/onboarding/components/CancelOnboardingButton.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { Button } from '@trycompai/ui/button'; +import { useAction } from 'next-safe-action/hooks'; +import { useState } from 'react'; +import { toast } from 'sonner'; +import { cancelOnboarding } from '../actions/cancel-onboarding'; + +interface CancelOnboardingButtonProps { + organizationId: string; + hasOtherOrgs: boolean; +} + +export function CancelOnboardingButton({ + organizationId, + hasOtherOrgs, +}: CancelOnboardingButtonProps) { + const [confirming, setConfirming] = useState(false); + + const cancelAction = useAction(cancelOnboarding, { + onSuccess: ({ data }) => { + if (data?.success) { + const target = data.fallbackOrgId ? `/${data.fallbackOrgId}` : '/setup'; + window.location.assign(target); + } else { + toast.error(data?.error || 'Failed to cancel'); + setConfirming(false); + } + }, + onError: ({ error }) => { + toast.error(error.serverError || 'Failed to cancel'); + setConfirming(false); + }, + }); + + if (!hasOtherOrgs) return null; + + if (!confirming) { + return ( + + ); + } + + return ( +
+ Delete this org? + + +
+ ); +} diff --git a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx index 8f3968fc0b..fb36ddfef9 100644 --- a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx +++ b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx @@ -11,17 +11,20 @@ import { AlertCircle, Loader2 } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import Balancer from 'react-wrap-balancer'; import { usePostPaymentOnboarding } from '../hooks/usePostPaymentOnboarding'; +import { CancelOnboardingButton } from './CancelOnboardingButton'; interface PostPaymentOnboardingProps { organization: Organization; initialData?: Record; userEmail?: string; + hasOtherOrgs?: boolean; } export function PostPaymentOnboarding({ organization, initialData = {}, userEmail, + hasOtherOrgs = false, }: PostPaymentOnboardingProps) { const { stepIndex, @@ -239,6 +242,10 @@ export function PostPaymentOnboarding({ )}
+ {stepIndex > 0 && ( {/* Form Section - Left Side */} @@ -51,6 +61,7 @@ export default async function SetupWithIdPage({ params, searchParams }: SetupPag setupId={setupId} initialData={setupSession.formData} currentStep={setupSession.currentStep} + hasOtherOrgs={existingOrgCount > 0} />
diff --git a/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx b/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx index 04d51c7aaf..947850d579 100644 --- a/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx +++ b/apps/app/src/app/(app)/setup/components/OnboardingFormActions.tsx @@ -11,6 +11,7 @@ interface OnboardingFormActionsProps { isOnboarding: boolean; // For the loader in the Finish button isCurrentStepValid: boolean; onPrefillAll?: () => void; + hasOtherOrgs?: boolean; } export function OnboardingFormActions({ @@ -21,6 +22,7 @@ export function OnboardingFormActions({ isOnboarding, isCurrentStepValid, onPrefillAll, + hasOtherOrgs = false, }: OnboardingFormActionsProps) { // Check if we're on localhost - use useState/useEffect to avoid hydration mismatch const [isLocalhost, setIsLocalhost] = useState(false); @@ -38,6 +40,17 @@ export function OnboardingFormActions({ return (
+ {hasOtherOrgs && ( + + )} {isLocalhost && onPrefillAll && stepIndex === 0 && (
diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/components/booking-step.tsx b/apps/app/src/app/(app)/upgrade/[orgId]/components/booking-step.tsx index f719a69acc..b243f55441 100644 --- a/apps/app/src/app/(app)/upgrade/[orgId]/components/booking-step.tsx +++ b/apps/app/src/app/(app)/upgrade/[orgId]/components/booking-step.tsx @@ -1,5 +1,6 @@ 'use client'; +import { CancelOnboardingButton } from '@/app/(app)/onboarding/components/CancelOnboardingButton'; import { Button } from '@trycompai/ui/button'; import { Card } from '@trycompai/ui/card'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@trycompai/ui/tooltip'; @@ -11,10 +12,12 @@ export function BookingStep({ company, orgId, hasAccess, + hasOtherOrgs = false, }: { company: string; orgId: string; hasAccess: boolean; + hasOtherOrgs?: boolean; }) { const [isCopied, setIsCopied] = useState(false); @@ -80,6 +83,14 @@ export function BookingStep({ + + {/* Cancel option */} +
+ +
diff --git a/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx b/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx index ff50a68b59..d33ae89074 100644 --- a/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx +++ b/apps/app/src/app/(app)/upgrade/[orgId]/page.tsx @@ -113,11 +113,26 @@ export default async function UpgradePage({ params }: PageProps) { redirect(`/${orgId}`); } + // Check if user has other completed orgs (for cancel button) + const otherOrgCount = await db.member.count({ + where: { + userId: authSession.user.id, + organizationId: { not: orgId }, + deactivated: false, + organization: { onboardingCompleted: true, hasAccess: true }, + }, + }); + return ( <>
- + 0} + />
); From b1dec0e8a8f55e50a001f47f6832e398ce19f3b3 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Apr 2026 14:08:10 -0400 Subject: [PATCH 5/9] =?UTF-8?q?fix(onboarding):=20harden=20cancel=20action?= =?UTF-8?q?=20=E2=80=94=20guard=20completed=20orgs,=20switch=20before=20de?= =?UTF-8?q?lete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reject cancel on orgs with onboardingCompleted=true - Switch activeOrganization BEFORE deleting so session never references a deleted org (prevents dangling session on slow client redirect) - Fail cancel if org switch fails rather than leaving orphaned state Co-Authored-By: Claude Opus 4.6 (1M context) --- .../onboarding/actions/cancel-onboarding.ts | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts b/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts index 8b98f95a7e..e2e70402c2 100644 --- a/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts +++ b/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts @@ -28,20 +28,25 @@ export const cancelOnboarding = authActionClientWithoutOrg return { success: false, error: 'Not authorized.' }; } - // Verify the user owns this org + // Verify the user owns this org and it's still incomplete const member = await db.member.findFirst({ where: { userId: session.user.id, organizationId: parsedInput.organizationId, role: { contains: 'owner' }, }, + include: { organization: { select: { onboardingCompleted: true } } }, }); if (!member) { return { success: false, error: 'Only the owner can cancel onboarding.' }; } - // Find a fallback org to switch to (completed, with access) + if (member.organization.onboardingCompleted) { + return { success: false, error: 'Cannot cancel a completed organization.' }; + } + + // Find a fallback org to switch to BEFORE deleting const fallbackOrg = await db.member.findFirst({ where: { userId: session.user.id, @@ -56,17 +61,8 @@ export const cancelOnboarding = authActionClientWithoutOrg orderBy: { createdAt: 'desc' }, }); - // Delete the incomplete org (cascade handles related records) - try { - await db.organization.delete({ - where: { id: parsedInput.organizationId }, - }); - } catch (error) { - console.error('Failed to delete organization:', error); - return { success: false, error: 'Failed to cancel onboarding.' }; - } - - // Switch to fallback org if available + // Switch active org BEFORE deletion so the session never + // references a deleted org (even if the client redirect is slow). if (fallbackOrg) { try { await auth.api.setActiveOrganization({ @@ -75,9 +71,20 @@ export const cancelOnboarding = authActionClientWithoutOrg }); } catch (error) { console.error('Failed to switch to fallback org:', error); + return { success: false, error: 'Failed to switch organization.' }; } } + // Delete the incomplete org (cascade handles related records) + try { + await db.organization.delete({ + where: { id: parsedInput.organizationId }, + }); + } catch (error) { + console.error('Failed to delete organization:', error); + return { success: false, error: 'Failed to cancel onboarding.' }; + } + return { success: true, fallbackOrgId: fallbackOrg?.organizationId ?? null, From 14a35df42f47b9d3f7bc7841182f14072e5ae95c Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Apr 2026 14:33:38 -0400 Subject: [PATCH 6/9] fix(onboarding): sanitize error messages shown to users Don't expose raw Prisma/DB error messages (like constraint violations or connection details) in user-facing toasts. Log the raw error to console for debugging, show a generic user-friendly message in the toast. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts b/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts index d7dde73cad..9ac806e8b7 100644 --- a/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts +++ b/apps/app/src/app/(app)/setup/hooks/useOnboardingForm.ts @@ -130,13 +130,15 @@ export function useOnboardingForm({ // Clear answers after successful creation setSavedAnswers({}); } else { - toast.error(data?.error || 'Failed to create organization'); + console.error('Organization creation failed:', data?.error); + toast.error('Failed to create organization. Please try again.'); setIsFinalizing(false); setIsOnboarding(false); } }, onError: ({ error }) => { - toast.error(error.serverError || 'Failed to create organization'); + console.error('Organization creation error:', error.serverError); + toast.error('Failed to create organization. Please try again.'); setIsFinalizing(false); setIsOnboarding(false); }, From 03452e38467865ed293e38b9ddf4263bc878379e Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Apr 2026 15:16:25 -0400 Subject: [PATCH 7/9] fix(onboarding): require fallback org before allowing cancel Refuse to delete org if no fallback org exists server-side. Prevents race condition where other orgs are removed between page render (which checks hasOtherOrgs) and action execution, which would leave the session pointing at a deleted org. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../onboarding/actions/cancel-onboarding.ts | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts b/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts index e2e70402c2..0044c7b06d 100644 --- a/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts +++ b/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts @@ -61,18 +61,23 @@ export const cancelOnboarding = authActionClientWithoutOrg orderBy: { createdAt: 'desc' }, }); + // Must have a fallback org — refuse to delete if there's nowhere to go. + // The UI guards this too, but a race condition could remove fallback orgs + // between page render and action execution. + if (!fallbackOrg) { + return { success: false, error: 'No other organization to switch to.' }; + } + // Switch active org BEFORE deletion so the session never // references a deleted org (even if the client redirect is slow). - if (fallbackOrg) { - try { - await auth.api.setActiveOrganization({ - headers: await headers(), - body: { organizationId: fallbackOrg.organizationId }, - }); - } catch (error) { - console.error('Failed to switch to fallback org:', error); - return { success: false, error: 'Failed to switch organization.' }; - } + try { + await auth.api.setActiveOrganization({ + headers: await headers(), + body: { organizationId: fallbackOrg.organizationId }, + }); + } catch (error) { + console.error('Failed to switch to fallback org:', error); + return { success: false, error: 'Failed to switch organization.' }; } // Delete the incomplete org (cascade handles related records) From 9b884f09f46221b61fa73f4fcc2f921613422302 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Apr 2026 15:30:17 -0400 Subject: [PATCH 8/9] fix(onboarding): rollback active org switch if delete fails If setActiveOrganization succeeds but organization.delete fails, roll back the active org to the original one so the session stays consistent with what the user sees on screen. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/(app)/onboarding/actions/cancel-onboarding.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts b/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts index 0044c7b06d..99c1722078 100644 --- a/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts +++ b/apps/app/src/app/(app)/onboarding/actions/cancel-onboarding.ts @@ -80,13 +80,22 @@ export const cancelOnboarding = authActionClientWithoutOrg return { success: false, error: 'Failed to switch organization.' }; } - // Delete the incomplete org (cascade handles related records) + // Delete the incomplete org (cascade handles related records). + // If this fails, roll back the active org switch to keep state consistent. try { await db.organization.delete({ where: { id: parsedInput.organizationId }, }); } catch (error) { console.error('Failed to delete organization:', error); + try { + await auth.api.setActiveOrganization({ + headers: await headers(), + body: { organizationId: parsedInput.organizationId }, + }); + } catch (rollbackError) { + console.error('Failed to rollback active org switch:', rollbackError); + } return { success: false, error: 'Failed to cancel onboarding.' }; } From 887dfa949c60671d3c54a070fb1401d216b1dc5f Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Apr 2026 15:57:00 -0400 Subject: [PATCH 9/9] fix(onboarding): hide cancel button while onboarding submission is in-flight Prevents race between org deletion and completeOnboarding by hiding the cancel button when isOnboarding or isFinalizing is true. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/(app)/onboarding/components/PostPaymentOnboarding.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx index fb36ddfef9..4a9fda707f 100644 --- a/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx +++ b/apps/app/src/app/(app)/onboarding/components/PostPaymentOnboarding.tsx @@ -244,7 +244,7 @@ export function PostPaymentOnboarding({
{stepIndex > 0 && (