diff --git a/.env.example b/.env.example index c4c2acd01..3a02a8b96 100644 --- a/.env.example +++ b/.env.example @@ -1,24 +1,64 @@ -# Uncomment according to src/lib/env.ts +# ================================= +# REQUIRED SERVER ENVIRONMENT VARIABLES +# ================================= +# Supabase service role key for admin operations +SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key -# KV_URL= -# KV_REST_API_READ_ONLY_TOKEN= -# KV_REST_API_TOKEN= -# KV_REST_API_URL= -# SUPABASE_SERVICE_ROLE_KEY= -# COOKIE_ENCRYPTION_KEY= +# Key for encrypting cookies - must be at least 32 characters long +COOKIE_ENCRYPTION_KEY=your_secure_cookie_encryption_key_here -BILLING_API_URL=https://billing.e2b.dev +# KV database configuration +KV_URL= +KV_REST_API_READ_ONLY_TOKEN= +KV_REST_API_TOKEN= +KV_REST_API_URL= -# NEXT_PUBLIC_POSTHOG_KEY= -# NEXT_PUBLIC_SUPABASE_URL= -# NEXT_PUBLIC_SUPABASE_ANON_KEY= -# NEXT_PUBLIC_STRIPE_BILLING_URL= +# ================================= +# REQUIRED CLIENT ENVIRONMENT VARIABLES +# ================================= +# Supabase URL and anon key for client-side operations +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key + +# API domain for your application NEXT_PUBLIC_DEFAULT_API_DOMAIN=e2b.dev -# Set to 1 to expose -NEXT_PUBLIC_EXPOSE_STORYBOOK=0 -NEXT_PUBLIC_SCAN=0 -NEXT_PUBLIC_MOCK_DATA=0 +# ================================= +# OPTIONAL SERVER ENVIRONMENT VARIABLES +# ================================= +# Billing API URL (Required if NEXT_PUBLIC_INCLUDE_BILLING=1) +# BILLING_API_URL=https://billing.e2b.dev + +# Vercel URL (automatically set in Vercel deployments) +# VERCEL_URL= + +# Development infrastructure API domain +# DEVELOPMENT_INFRA_API_DOMAIN= + +# Sentry authentication token for error reporting +# SENTRY_AUTH_TOKEN= + +# ZeroBounce API key for email validation +# ZEROBOUNCE_API_KEY= + +# ================================= +# OPTIONAL CLIENT ENVIRONMENT VARIABLES +# ================================= +# PostHog analytics key +# NEXT_PUBLIC_POSTHOG_KEY= + +# Enable billing features: set to 1 to enable +# When enabled, both BILLING_API_URL and NEXT_PUBLIC_STRIPE_BILLING_URL must be provided +# NEXT_PUBLIC_INCLUDE_BILLING=0 + +# Stripe billing URL (Required if NEXT_PUBLIC_INCLUDE_BILLING=1) +# NEXT_PUBLIC_STRIPE_BILLING_URL=https://billing.stripe.com/your-path + +# Set to 1 to expose Storybook +# NEXT_PUBLIC_EXPOSE_STORYBOOK=0 + +# Set to 1 to enable scanning +# NEXT_PUBLIC_SCAN=0 -# For applying migrations -# POSTGRES_CONNECTION_STRING= +# Set to 1 to use mock data +# NEXT_PUBLIC_MOCK_DATA=0 \ No newline at end of file diff --git a/package.json b/package.json index 00d0545ab..ae6f7491f 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "<<<<<< Development": "", "storybook": "storybook dev -p 6006", "shad": "bunx shadcn@canary", - "prebuild": "bun scripts:build-storybook", + "prebuild": "bun scripts:check-app-env && bun scripts:build-storybook", "postinstall": "fumadocs-mdx", "<<<<<< Testing": "", "test:run": "bun scripts:check-all-env && vitest run", diff --git a/scripts/check-app-env.ts b/scripts/check-app-env.ts index 45f7dbc4c..68bf2683f 100644 --- a/scripts/check-app-env.ts +++ b/scripts/check-app-env.ts @@ -4,4 +4,19 @@ import { loadEnvConfig } from '@next/env' const projectDir = process.cwd() loadEnvConfig(projectDir) -validateEnv(serverSchema.merge(clientSchema)) +const schema = serverSchema.merge(clientSchema).refine( + (data) => { + if (data.NEXT_PUBLIC_INCLUDE_BILLING === '1') { + return !!data.BILLING_API_URL && !!data.NEXT_PUBLIC_STRIPE_BILLING_URL + } + + return true + }, + { + message: + 'NEXT_PUBLIC_INCLUDE_BILLING is enabled, but either BILLING_API_URL or NEXT_PUBLIC_STRIPE_BILLING_URL is missing', + path: ['BILLING_API_URL', 'NEXT_PUBLIC_STRIPE_BILLING_URL'], + } +) + +validateEnv(schema) diff --git a/src/app/dashboard/[teamIdOrSlug]/usage/page.tsx b/src/app/dashboard/[teamIdOrSlug]/usage/page.tsx index c0cc74f0b..699c263b5 100644 --- a/src/app/dashboard/[teamIdOrSlug]/usage/page.tsx +++ b/src/app/dashboard/[teamIdOrSlug]/usage/page.tsx @@ -30,13 +30,7 @@ async function UsagePageContent({ teamId }: { teamId: string }) { const res = await getUsage({ teamId }) if (!res?.data || res.serverError || res.validationErrors) { - return ( - - ) + throw new Error(res?.serverError || 'Failed to load usage') } const data = res.data diff --git a/src/app/dashboard/error.tsx b/src/app/dashboard/error.tsx index bc029c3e7..b107605a6 100644 --- a/src/app/dashboard/error.tsx +++ b/src/app/dashboard/error.tsx @@ -1,12 +1,15 @@ 'use client' -import { UnknownError } from '@/types/errors' import ErrorBoundary from '@/ui/error' -export default function DashboardError() { +export default function DashboardError({ + error, +}: { + error: Error & { digest?: string } +}) { return ( diff --git a/src/app/error.tsx b/src/app/error.tsx index aa9898f76..3e14002a4 100644 --- a/src/app/error.tsx +++ b/src/app/error.tsx @@ -1,8 +1,16 @@ 'use client' -import { createErrorBoundary } from '@/lib/create-error-boundary' +import ErrorBoundary from '@/ui/error' -export default createErrorBoundary({ - title: 'Something went wrong', - description: 'An unexpected error occurred', -}) +export default function Error({ + error, +}: { + error: Error & { digest?: string } +}) { + return ( + + ) +} diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index 23a4a9fd8..0f7cb255c 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -1,6 +1,6 @@ 'use client' -import { SentryErrorBoundary } from '@/ui/sentry-error-boundary' +import ErrorBoundary from '@/ui/error' export default function GlobalError({ error, @@ -8,11 +8,9 @@ export default function GlobalError({ error: Error & { digest?: string } }) { return ( - ) } diff --git a/src/configs/dashboard-navs.ts b/src/configs/dashboard-navs.ts index 9fbee083b..6f91732c7 100644 --- a/src/configs/dashboard-navs.ts +++ b/src/configs/dashboard-navs.ts @@ -9,6 +9,7 @@ import { Users, } from 'lucide-react' import { ForwardRefExoticComponent, RefAttributes } from 'react' +import { INCLUDE_BILLING } from './flags' type DashboardNavLinkArgs = { teamIdOrSlug?: string @@ -35,11 +36,16 @@ export const MAIN_DASHBOARD_LINKS: DashboardNavLink[] = [ href: (args) => `/dashboard/${args.teamIdOrSlug}/templates`, icon: Container, }, - { - label: 'Usage', - href: (args) => `/dashboard/${args.teamIdOrSlug}/usage`, - icon: Activity, - }, + ...(INCLUDE_BILLING + ? [ + { + label: 'Usage', + href: (args: DashboardNavLinkArgs) => + `/dashboard/${args.teamIdOrSlug}/usage`, + icon: Activity, + }, + ] + : []), { label: 'Team', @@ -53,16 +59,23 @@ export const MAIN_DASHBOARD_LINKS: DashboardNavLink[] = [ icon: Key, group: 'manage', }, - { - label: 'Billing', - href: (args) => `/dashboard/${args.teamIdOrSlug}/billing`, - icon: CreditCard, - group: 'expenses', - }, - { - label: 'Budget', - href: (args) => `/dashboard/${args.teamIdOrSlug}/budget`, - group: 'expenses', - icon: DollarSign, - }, + + ...(INCLUDE_BILLING + ? [ + { + label: 'Billing', + href: (args: DashboardNavLinkArgs) => + `/dashboard/${args.teamIdOrSlug}/billing`, + icon: CreditCard, + group: 'expenses', + }, + { + label: 'Budget', + href: (args: DashboardNavLinkArgs) => + `/dashboard/${args.teamIdOrSlug}/budget`, + group: 'expenses', + icon: DollarSign, + }, + ] + : []), ] diff --git a/src/configs/flags.ts b/src/configs/flags.ts new file mode 100644 index 000000000..a4728e522 --- /dev/null +++ b/src/configs/flags.ts @@ -0,0 +1 @@ +export const INCLUDE_BILLING = process.env.NEXT_PUBLIC_INCLUDE_BILLING === '1' diff --git a/src/features/client-providers.tsx b/src/features/client-providers.tsx index 6bbc63cdf..4cbbe5eb7 100644 --- a/src/features/client-providers.tsx +++ b/src/features/client-providers.tsx @@ -32,6 +32,10 @@ export default function ClientProviders({ children }: ClientProvidersProps) { export function PostHogProvider({ children }: { children: React.ReactNode }) { useEffect(() => { + if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) { + return + } + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { // Note that PostHog will automatically capture page views and common events // diff --git a/src/features/dashboard/billing/credits-content.tsx b/src/features/dashboard/billing/credits-content.tsx index 93ac20eb5..4fc4ffc23 100644 --- a/src/features/dashboard/billing/credits-content.tsx +++ b/src/features/dashboard/billing/credits-content.tsx @@ -1,5 +1,4 @@ import { getUsage } from '@/server/usage/get-usage' -import { ErrorIndicator } from '@/ui/error-indicator' export default async function BillingCreditsContent({ teamId, @@ -9,15 +8,7 @@ export default async function BillingCreditsContent({ const res = await getUsage({ teamId }) if (!res?.data || res.serverError) { - return ( -
- -
- ) + throw new Error(res?.serverError || 'Failed to load credits') } const usage = res.data diff --git a/src/features/dashboard/budget/usage-limits.tsx b/src/features/dashboard/budget/usage-limits.tsx index b3d4377a4..256caabf4 100644 --- a/src/features/dashboard/budget/usage-limits.tsx +++ b/src/features/dashboard/budget/usage-limits.tsx @@ -2,8 +2,6 @@ import { getBillingLimits } from '@/server/billing/get-billing-limits' import LimitCard from './limit-card' import AlertCard from './alert-card' import { cn } from '@/lib/utils' -import Dotted from '@/ui/dotted' -import { ErrorIndicator } from '@/ui/error-indicator' interface UsageLimitsProps { className?: string @@ -17,15 +15,7 @@ export default async function UsageLimits({ const res = await getBillingLimits({ teamId }) if (!res?.data || res.serverError || res.validationErrors) { - return ( -
- -
- ) + throw new Error(res?.serverError || 'Failed to load usage limits') } const limits = res.data diff --git a/src/features/dashboard/page-layout.tsx b/src/features/dashboard/page-layout.tsx index 1c21ae468..f801eb819 100644 --- a/src/features/dashboard/page-layout.tsx +++ b/src/features/dashboard/page-layout.tsx @@ -4,6 +4,9 @@ import { Suspense } from 'react' import SidebarMobile from './sidebar/sidebar-mobile' import Frame from '@/ui/frame' import { DashboardSurveyPopover } from './navbar/dashboard-survey-popover' +import { ErrorBoundary } from 'react-error-boundary' +import E2BErrorBoundary, { CatchErrorBoundary } from '@/ui/error' +import { UnknownError } from '@/types/errors' interface DashboardPageLayoutProps { children: React.ReactNode @@ -43,14 +46,16 @@ export default async function DashboardPageLayout({ - - {children} - - {children} + + + {children} + + {children} + ) } diff --git a/src/lib/create-error-boundary.tsx b/src/lib/create-error-boundary.tsx deleted file mode 100644 index 20f12eee8..000000000 --- a/src/lib/create-error-boundary.tsx +++ /dev/null @@ -1,40 +0,0 @@ -'use client' - -import { SentryErrorBoundary } from '@/ui/sentry-error-boundary' - -interface CreateErrorBoundaryOptions { - title?: string - description?: string -} - -/** - * Helper function to create error.tsx files for any route in the application - * - * Usage: - * ``` - * // src/app/some-route/error.tsx - * export default createErrorBoundary({ - * title: 'Route Error', - * description: 'Something went wrong in this route' - * }) - * ``` - */ -export function createErrorBoundary(options: CreateErrorBoundaryOptions = {}) { - return function ErrorBoundary({ - error, - reset, - }: { - error: Error & { digest?: string } - reset: () => void - }) { - return ( - - ) - } -} diff --git a/src/lib/env.ts b/src/lib/env.ts index 8e3a5c40e..78ce1eeb2 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -1,10 +1,10 @@ import { z } from 'zod' export const serverSchema = z.object({ - SUPABASE_SERVICE_ROLE_KEY: z.string(), - BILLING_API_URL: z.string().url(), - COOKIE_ENCRYPTION_KEY: z.string(), + SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), + COOKIE_ENCRYPTION_KEY: z.string().min(32), + BILLING_API_URL: z.string().url().optional(), VERCEL_URL: z.string().optional(), DEVELOPMENT_INFRA_API_DOMAIN: z.string().optional(), SENTRY_AUTH_TOKEN: z.string().optional(), @@ -12,11 +12,13 @@ export const serverSchema = z.object({ }) export const clientSchema = z.object({ - NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1), NEXT_PUBLIC_SUPABASE_URL: z.string().url(), NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1), NEXT_PUBLIC_DEFAULT_API_DOMAIN: z.string(), - NEXT_PUBLIC_STRIPE_BILLING_URL: z.string().url(), + + NEXT_PUBLIC_POSTHOG_KEY: z.string().min(1).optional(), + NEXT_PUBLIC_INCLUDE_BILLING: z.string().optional(), + NEXT_PUBLIC_STRIPE_BILLING_URL: z.string().url().optional(), NEXT_PUBLIC_EXPOSE_STORYBOOK: z.string().optional(), NEXT_PUBLIC_SCAN: z.string().optional(), NEXT_PUBLIC_MOCK_DATA: z.string().optional(), @@ -31,13 +33,10 @@ export const testEnvSchema = z.object({ * You can't destruct `process.env` as a regular object, so we do * a simple validation of the environment variables we need. */ -export const formatErrors = ( - errors: z.ZodFormattedError, string> -) => - Object.entries(errors) +export const formatErrors = (errors: z.inferFlattenedErrors) => + Object.entries(errors.fieldErrors) .map(([name, value]) => { - if (value && '_errors' in value) - return `${name}: ${value._errors.join(', ')}\n` + if (value) return `${name}: ${value.join(', ')}\n` }) .filter(Boolean) @@ -49,10 +48,10 @@ export function validateEnv(schema: z.ZodSchema) { if (!parsed.success) { console.error( - '❌ Invalid environment variables:\n', - ...formatErrors(parsed.error.format()) + '❌ Invalid environment variables:\n\n', + ...formatErrors(parsed.error.flatten()) ) - throw new Error('Invalid environment variables') + process.exit(1) } console.log('✅ Environment variables validated successfully') diff --git a/src/ui/error-indicator.tsx b/src/ui/error-indicator.tsx index e9ccb0f79..f278ceffd 100644 --- a/src/ui/error-indicator.tsx +++ b/src/ui/error-indicator.tsx @@ -31,20 +31,15 @@ export function ErrorIndicator({ const [isPending, startTransition] = useTransition() return ( - + {title} - + {description} {message && ( - +

{message}

)} @@ -55,7 +50,7 @@ export function ErrorIndicator({ className="w-full max-w-md gap-2" > Refresh diff --git a/src/ui/error.tsx b/src/ui/error.tsx index 7d8161ace..147d3396c 100644 --- a/src/ui/error.tsx +++ b/src/ui/error.tsx @@ -2,11 +2,11 @@ import { useEffect } from 'react' import { ErrorIndicator } from './error-indicator' -import { logger } from '@/lib/clients/logger' +import { logError } from '@/lib/clients/logger' import Frame from './frame' import { cn } from '@/lib/utils' - -// TODO: log error to sentry +import * as Sentry from '@sentry/nextjs' +import { ErrorBoundary as ReactErrorBoundary } from 'react-error-boundary' export default function ErrorBoundary({ error, @@ -18,14 +18,46 @@ export default function ErrorBoundary({ className?: string }) { useEffect(() => { - logger.error('Error boundary caught:', error) + if (Sentry.isInitialized()) { + Sentry.captureException(error, { + level: 'fatal', + tags: { + component: 'ErrorBoundary', + }, + }) + } else { + logError('Error boundary caught:', error) + } }, [error]) return ( -
+
- +
) } + +export function CatchErrorBoundary({ + children, +}: { + children: React.ReactNode +}) { + return ( + } + > + {children} + + ) +} diff --git a/src/ui/sentry-error-boundary.tsx b/src/ui/sentry-error-boundary.tsx deleted file mode 100644 index 4c6f9e514..000000000 --- a/src/ui/sentry-error-boundary.tsx +++ /dev/null @@ -1,65 +0,0 @@ -'use client' - -import * as Sentry from '@sentry/nextjs' -import { useEffect } from 'react' -import { ErrorIndicator } from '@/ui/error-indicator' -import { useRouter } from 'next/navigation' - -interface SentryErrorBoundaryProps { - error: Error & { digest?: string } - title?: string - description?: string - resetErrorBoundary?: () => void - preserveLayout?: boolean -} - -export function SentryErrorBoundary({ - error, - title = 'Something went wrong', - description = 'Sorry, an unexpected error has occurred.', - resetErrorBoundary, - preserveLayout = true, -}: SentryErrorBoundaryProps) { - const router = useRouter() - - useEffect(() => { - // Automatically report all errors to Sentry - Sentry.captureException(error) - }, [error]) - - const handleReset = () => { - if (resetErrorBoundary) { - resetErrorBoundary() - } else { - router.refresh() - } - } - - // If we're preserving the layout, just render the error component - if (preserveLayout) { - return ( -
- -
- ) - } - - // For global errors that replace the entire page - return ( - - - - - - ) -}