diff --git a/.husky/commit-msg b/.husky/commit-msg old mode 100644 new mode 100755 index cfe751017..0d3dc4e83 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1 +1,2 @@ -pnpm commitlint --edit $1 +#!/usr/bin/env bash +pnpm commitlint --edit "$1" diff --git a/.husky/pre-commit b/.husky/pre-commit old mode 100644 new mode 100755 index cb2c84d5c..d7158e659 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,2 @@ +#!/usr/bin/env bash pnpm lint-staged diff --git a/apps/ui/src/app/globals.css b/apps/ui/src/app/globals.css index dda742a9f..db273543a 100644 --- a/apps/ui/src/app/globals.css +++ b/apps/ui/src/app/globals.css @@ -7,16 +7,30 @@ body { @apply m-0; - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", - "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; + font-family: var( + --font-geist-sans, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Inter", + "Segoe UI", + "Roboto", + "Oxygen", + "Ubuntu", + "Cantarell", + "Fira Sans", + "Droid Sans", + "Helvetica Neue", + sans-serif + ); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } code { font-family: - source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; + var(--font-geist-mono), source-code-pro, Menlo, Monaco, Consolas, + "Courier New", monospace; } :root { @@ -129,18 +143,22 @@ code { --color-sidebar-ring: var(--sidebar-ring); --animate-marquee: marquee var(--duration) infinite linear; --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite; + @keyframes marquee { from { transform: translateX(0); } + to { transform: translateX(calc(-100% - var(--gap))); } } + @keyframes marquee-vertical { from { transform: translateY(0); } + to { transform: translateY(calc(-100% - var(--gap))); } @@ -151,6 +169,7 @@ code { * { @apply border-border outline-ring/50; } + body { @apply bg-background text-foreground; } @@ -159,6 +178,7 @@ code { from { height: 0; } + to { height: var(--radix-accordion-content-height); } @@ -168,6 +188,7 @@ code { from { height: var(--radix-accordion-content-height); } + to { height: 0; } diff --git a/apps/ui/src/app/layout.tsx b/apps/ui/src/app/layout.tsx index 0ee43c8ed..429546e53 100644 --- a/apps/ui/src/app/layout.tsx +++ b/apps/ui/src/app/layout.tsx @@ -49,10 +49,12 @@ export default function RootLayout({ children }: { children: ReactNode }) { const config = getConfig(); return ( - - + + {children} diff --git a/apps/ui/src/app/onboarding/onboarding-client.tsx b/apps/ui/src/app/onboarding/onboarding-client.tsx index acbca9a7c..2a39a5f67 100644 --- a/apps/ui/src/app/onboarding/onboarding-client.tsx +++ b/apps/ui/src/app/onboarding/onboarding-client.tsx @@ -4,21 +4,53 @@ import { useRouter } from "next/navigation"; import { useEffect } from "react"; import { OnboardingWizard } from "@/components/onboarding/onboarding-wizard"; +import { UserProvider } from "@/components/providers/user-provider"; import { useUser } from "@/hooks/useUser"; export function OnboardingClient() { const router = useRouter(); - const { user } = useUser(); + const { user, isLoading, error } = useUser(); useEffect(() => { - if (!user) { + if (!isLoading && !user && !error) { router.push("/login"); } - }, [user, router]); + }, [user, isLoading, error, router]); + + if (isLoading) { + return ( +
+
+
+

Loading...

+
+
+ ); + } + + if (error) { + return ( +
+
+

Failed to load user data

+ +
+
+ ); + } if (!user) { return null; } - return ; + return ( + + + + ); } diff --git a/apps/ui/src/app/onboarding/page.tsx b/apps/ui/src/app/onboarding/page.tsx index bca3bc5c1..59c534c93 100644 --- a/apps/ui/src/app/onboarding/page.tsx +++ b/apps/ui/src/app/onboarding/page.tsx @@ -1,22 +1,8 @@ -import { UserProvider } from "@/components/providers/user-provider"; -import { fetchServerData } from "@/lib/server-api"; - import { OnboardingClient } from "./onboarding-client"; -import type { User } from "@/lib/types"; - -// Force dynamic rendering since this page uses server-side data fetching with cookies +// Force dynamic rendering since this page uses client-side data fetching export const dynamic = "force-dynamic"; -export default async function OnboardingPage() { - const initialUserData = await fetchServerData<{ user: User }>( - "GET", - "/user/me", - ); - - return ( - - - - ); +export default function OnboardingPage() { + return ; } diff --git a/apps/ui/src/components/onboarding/onboarding-wizard.tsx b/apps/ui/src/components/onboarding/onboarding-wizard.tsx index c7c682353..e057ef8f3 100644 --- a/apps/ui/src/components/onboarding/onboarding-wizard.tsx +++ b/apps/ui/src/components/onboarding/onboarding-wizard.tsx @@ -6,19 +6,23 @@ import { usePostHog } from "posthog-js/react"; import * as React from "react"; import { useState } from "react"; -import { Card, CardContent } from "@/lib/components/card"; +import { + NavigationMenu, + NavigationMenuItem, + NavigationMenuList, +} from "@/lib/components/navigation-menu"; import { Stepper } from "@/lib/components/stepper"; import { useApi } from "@/lib/fetch-client"; +import Logo from "@/lib/icons/Logo"; import { useStripe } from "@/lib/stripe"; -import { ApiKeyStep } from "./api-key-step"; import { CreditsStep } from "./credits-step"; import { PlanChoiceStep } from "./plan-choice-step"; import { ProviderKeyStep } from "./provider-key-step"; import { ReferralStep } from "./referral-step"; import { WelcomeStep } from "./welcome-step"; -type FlowType = "credits" | "byok" | null; +type FlowType = "credits" | "byok" | "free" | null; const getSteps = (flowType: FlowType) => [ { @@ -27,16 +31,12 @@ const getSteps = (flowType: FlowType) => [ }, { id: "referral", - title: "How did you hear about us?", + title: "How did you find us?", optional: true, }, - { - id: "api-key", - title: "API Key", - }, { id: "plan-choice", - title: "Choose Plan", + title: "Choose plan", }, { id: flowType === "credits" ? "credits" : "provider-key", @@ -49,9 +49,11 @@ export function OnboardingWizard() { const [activeStep, setActiveStep] = useState(0); const [flowType, setFlowType] = useState(null); const [hasSelectedPlan, setHasSelectedPlan] = useState(false); + const [selectedPlanName, setSelectedPlanName] = useState(""); const [isPaymentSuccessful, setIsPaymentSuccessful] = useState(false); const [referralSource, setReferralSource] = useState(""); const [referralDetails, setReferralDetails] = useState(""); + const router = useRouter(); const posthog = usePostHog(); const { stripe, isLoading: stripeLoading } = useStripe(); @@ -65,8 +67,14 @@ export function OnboardingWizard() { const STEPS = getSteps(flowType); const handleStepChange = async (step: number) => { - // Special handling for plan choice step (now at index 3) - if (activeStep === 3) { + // Handle backward navigation + if (step < activeStep) { + setActiveStep(step); + return; + } + + // Special handling for plan choice step (now at index 2) + if (activeStep === 2) { if (!hasSelectedPlan) { // Skip to dashboard if no plan selected posthog.capture("onboarding_skipped", { @@ -74,10 +82,15 @@ export function OnboardingWizard() { referralSource: referralSource || "not_provided", referralDetails: referralDetails || undefined, }); - await completeOnboarding.mutateAsync({}); - const queryKey = api.queryOptions("get", "/user/me").queryKey; - await queryClient.invalidateQueries({ queryKey }); - router.push("/dashboard"); + try { + await completeOnboarding.mutateAsync({}); + const queryKey = api.queryOptions("get", "/user/me").queryKey; + await queryClient.invalidateQueries({ queryKey }); + router.push("/dashboard"); + } catch (err) { + // Keep user on the current step and log the failure + console.error("Failed to complete onboarding:", err); + } return; } // If plan is selected, continue to next step @@ -103,13 +116,39 @@ export function OnboardingWizard() { const handleSelectCredits = () => { setFlowType("credits"); setHasSelectedPlan(true); - setActiveStep(4); + setSelectedPlanName("Buy Credits"); + setActiveStep(3); }; const handleSelectBYOK = () => { setFlowType("byok"); setHasSelectedPlan(true); - setActiveStep(4); + setSelectedPlanName("Bring Your Own Keys"); + setActiveStep(3); + }; + + const handleSelectFreePlan = async () => { + setHasSelectedPlan(true); + setSelectedPlanName("Free Plan"); + setFlowType("free"); + + // Complete onboarding directly for free plan + posthog.capture("onboarding_completed", { + completedSteps: ["welcome", "referral", "plan-choice"], + flowType: "free", + referralSource: referralSource || "not_provided", + referralDetails: referralDetails || undefined, + }); + + try { + await completeOnboarding.mutateAsync({}); + const queryKey = api.queryOptions("get", "/user/me").queryKey; + await queryClient.invalidateQueries({ queryKey }); + router.push("/dashboard"); + } catch (err) { + // Keep user on the current step and log the failure + console.error("Failed to complete onboarding:", err); + } }; const handleReferralComplete = (source: string, details?: string) => { @@ -117,23 +156,23 @@ export function OnboardingWizard() { if (details) { setReferralDetails(details); } - setActiveStep(2); // Move to API Key step + setActiveStep(2); // Move to Plan Choice step }; // Special handling for PlanChoiceStep to pass callbacks const renderCurrentStep = () => { - if (activeStep === 3) { + if (activeStep === 2) { return ( ); } // For credits step, wrap with Stripe Elements - if (activeStep === 4 && flowType === "credits") { + if (activeStep === 3 && flowType === "credits") { return stripeLoading ? (
Loading payment form...
) : ( @@ -144,7 +183,7 @@ export function OnboardingWizard() { } // For BYOK step - if (activeStep === 4 && flowType === "byok") { + if (activeStep === 3 && flowType === "byok") { return ; } @@ -157,10 +196,6 @@ export function OnboardingWizard() { return ; } - if (activeStep === 2) { - return ; - } - return null; }; @@ -168,13 +203,22 @@ export function OnboardingWizard() { const getStepperSteps = () => { return STEPS.map((step, index) => ({ ...step, - // Make plan choice step show Skip when no selection - ...(index === 3 && - !hasSelectedPlan && { - customNextText: "Skip", - }), + // Welcome step shows dynamic text based on user state + ...(index === 0 && { + customNextText: "Next: How did you find us?", + }), + // Referral step shows dynamic text based on user state + ...(index === 1 && { + customNextText: "Next: Choose plan", + }), + // Make plan choice step show dynamic text based on selected plan + ...(index === 2 && { + customNextText: hasSelectedPlan + ? `Continue with ${selectedPlanName}` + : "Skip", + }), // Remove optional status from credits step when payment is successful - ...(index === 4 && + ...(index === 3 && flowType === "credits" && isPaymentSuccessful && { optional: false, @@ -183,22 +227,31 @@ export function OnboardingWizard() { }; return ( -
- - - - {renderCurrentStep()} - - - +
+ + + +
+ + + LLM Gateway + +
+
+
+
+ + + {renderCurrentStep()} +
); } diff --git a/apps/ui/src/components/onboarding/plan-choice-step.tsx b/apps/ui/src/components/onboarding/plan-choice-step.tsx index d28d8c45f..49462e4d1 100644 --- a/apps/ui/src/components/onboarding/plan-choice-step.tsx +++ b/apps/ui/src/components/onboarding/plan-choice-step.tsx @@ -1,8 +1,17 @@ -import { CreditCard, Key, Lock, ArrowRight } from "lucide-react"; -import * as React from "react"; +import { + ArrowRight, + Building, + CheckCircle, + CreditCard, + Gift, + Key, + Lock, +} from "lucide-react"; +import React from "react"; import { UpgradeToProDialog } from "@/components/shared/upgrade-to-pro-dialog"; import { useDefaultOrganization } from "@/hooks/useOrganization"; +import { Badge } from "@/lib/components/badge"; import { Button } from "@/lib/components/button"; import { Card, @@ -17,148 +26,263 @@ import { useAppConfig } from "@/lib/config"; interface PlanChoiceStepProps { onSelectCredits: () => void; onSelectBYOK: () => void; - hasSelectedPlan?: boolean; + onSelectFreePlan: () => void; } export function PlanChoiceStep({ onSelectCredits, onSelectBYOK, + onSelectFreePlan, }: PlanChoiceStepProps) { const config = useAppConfig(); const { data: organization } = useDefaultOrganization(); const isProPlan = organization?.plan === "pro"; + const isLocalhost = !config.hosted; + + const plans = [ + { + id: "free", + name: "Free Plan", + description: "Perfect for getting started and testing", + icon: Gift, + price: "$0", + period: "forever", + current: false, + features: [ + "Access to ALL models", + "Pay with credits", + "5% LLMGateway fee on credit usage", + "3-day data retention", + "Standard support", + ], + color: "green", + buttonText: "Choose Free Plan", + buttonVariant: "default" as const, + buttonDisabled: false, + onClick: onSelectFreePlan, + }, + { + id: "credits", + name: "Credits", + description: "Buy credits to use our managed service", + icon: CreditCard, + price: "Pay as you go", + period: "", + current: false, + features: [ + "Simple pay-as-you-go pricing", + "No API key management needed", + "Built-in rate limiting and monitoring", + "Works with any plan", + ], + color: "blue", + buttonText: config.hosted + ? "Choose Credits" + : "Only available on llmgateway.io", + buttonVariant: "default" as const, + buttonDisabled: !config.hosted, + onClick: onSelectCredits, + }, + { + id: "byok", + name: "BYOK", + description: "Use your own API keys", + icon: Key, + price: "$50", + period: "/month", + current: false, + features: [ + "Full control over provider costs", + "Direct billing from providers", + "Custom rate limits and quotas", + "Advanced enterprise features", + ], + color: "purple", + buttonText: isProPlan ? "Choose BYOK" : "Upgrade to Pro for BYOK", + buttonVariant: "outline" as const, + buttonDisabled: false, + onClick: onSelectBYOK, + requiresPro: !isProPlan, + isPro: true, + }, + { + id: "enterprise", + name: "Enterprise", + description: "Custom solutions for large orgs", + icon: Building, + price: "Custom pricing", + period: "", + current: false, + features: [ + "Everything in Pro", + "Advanced security features", + "Custom integrations", + "Unlimited data retention", + "24/7 premium support", + ], + color: "gray", + buttonText: "Contact Sales", + buttonVariant: "outline" as const, + buttonDisabled: false, + onClick: () => window.open(`mailto:${config.contactEmail}`, "_blank"), + }, + ]; + + const getColorClasses = ( + color: string, + current: boolean, + isPro?: boolean, + ) => { + if (current) { + return { + card: "ring-2 ring-green-500 border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950", + dot: "bg-green-500", + }; + } + if (isPro) { + return { + card: "ring-1 ring-gray-300 border-gray-400 bg-white dark:bg-gray-900 dark:border-gray-200 dark:ring-gray-200", + dot: "bg-gray-600 dark:bg-gray-400", + }; + } + switch (color) { + case "blue": + return { card: "", dot: "bg-blue-500" }; + case "purple": + return { card: "", dot: "bg-purple-500" }; + case "gray": + return { card: "", dot: "bg-gray-500" }; + default: + return { card: "", dot: "bg-green-500" }; + } + }; return ( - -
+ +
-

Choose Your Approach

+

Choose plan

- Select how you'd like to use LLM Gateway, or skip to continue - with the free plan + {isLocalhost + ? "You're self-hosting (detected) - explore all your options below, or skip to continue with what you have." + : isProPlan + ? "You're on the Pro plan. Explore additional options below, or skip to continue building." + : "You're currently on the Free plan. Explore upgrade options below, or skip to continue building."}

+
-
- {/* Credits Option */} - - - - - Buy Credits - - - Use our managed service with pay-as-you-go credits - - - -
-
    -
  • -
    - Simple pay-as-you-go pricing -
  • -
  • -
    - No API key management needed -
  • -
  • -
    - Built-in rate limiting and monitoring -
  • -
  • -
    - Works with any plan -
  • -
-
- -
-
- - {/* BYOK Option */} - - - - - Bring Your Own Keys - {!isProPlan && ( - - Pro Plan Required - - )} - - - Use your own API keys for LLM providers - - - -
-
    -
  • -
    - Full control over provider costs -
  • -
  • -
    - Direct billing from providers -
  • -
  • -
    - Custom rate limits and quotas -
  • -
  • -
    - Advanced enterprise features -
  • -
+ {/* Responsive grid container */} +
+ {plans.map((plan) => { + const colorClasses = getColorClasses( + plan.color, + plan.current, + plan.isPro, + ); + const IconComponent = plan.icon; - {!isProPlan && ( -
-

- Pro Plan Required -

-

- BYOK requires a Pro plan subscription to access advanced - features and custom provider support. -

+ return ( + + +
+
+ + {plan.name}
- )} -
+ {plan.current && ( + + Active + + )} + {plan.requiresPro && ( + + Pro + + )} +
+ + {plan.description} + +
+ {plan.price} + {plan.period && ( + + {plan.period} + + )} +
+ + +
+
    + {plan.features.map((feature, index) => ( +
  • +
    + {feature} +
  • + ))} +
- {isProPlan ? ( - - ) : ( - - - - )} - - -
+ {plan.requiresPro && ( +
+

+ Pro Plan Required +

+

+ BYOK requires a Pro plan subscription. +

+
+ )} +
+ +
+ {plan.requiresPro ? ( + + + + ) : ( + + )} +
+ + + ); + })}
); diff --git a/apps/ui/src/components/onboarding/referral-step.tsx b/apps/ui/src/components/onboarding/referral-step.tsx index f5fc93d2f..969262ef0 100644 --- a/apps/ui/src/components/onboarding/referral-step.tsx +++ b/apps/ui/src/components/onboarding/referral-step.tsx @@ -1,14 +1,8 @@ "use client"; -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; -import { Button } from "@/lib/components/button"; -import { - Card, - CardContent, - CardHeader, - CardTitle, -} from "@/lib/components/card"; +import { Card, CardContent } from "@/lib/components/card"; import { Input } from "@/lib/components/input"; import { Label } from "@/lib/components/label"; import { RadioGroup, RadioGroupItem } from "@/lib/components/radio-group"; @@ -20,6 +14,12 @@ interface ReferralStepProps { export function ReferralStep({ onComplete }: ReferralStepProps) { const [selectedSource, setSelectedSource] = useState(""); const [otherDetails, setOtherDetails] = useState(""); + const onCompleteRef = useRef(onComplete); + + // Keep the ref updated with the latest onComplete function + useEffect(() => { + onCompleteRef.current = onComplete; + }, [onComplete]); const referralSources = [ { value: "twitter", label: "X (Formerly Twitter)" }, @@ -30,33 +30,45 @@ export function ReferralStep({ onComplete }: ReferralStepProps) { { value: "other", label: "Other (Specify)" }, ]; - const handleContinue = () => { - if (selectedSource) { - const details = selectedSource === "other" ? otherDetails : undefined; - onComplete?.(selectedSource, details); + // Handle completion when a source is selected or manually triggered + useEffect(() => { + if (selectedSource && selectedSource !== "other") { + // Auto-complete for non-"other" selections after a brief delay + const timer = setTimeout(() => { + onCompleteRef.current?.(selectedSource); + }, 1000); // Increased delay to give users time to change their mind + return () => clearTimeout(timer); } - }; + // Return empty cleanup function if condition is not met + return () => {}; + }, [selectedSource]); - const handleSkip = () => { - onComplete?.(""); - }; + // Handle "other" completion when details are provided + useEffect(() => { + if (selectedSource === "other" && otherDetails.trim()) { + const timer = setTimeout(() => { + onCompleteRef.current?.(selectedSource, otherDetails); + }, 1500); // Longer delay for typing + return () => clearTimeout(timer); + } + // Return empty cleanup function if condition is not met + return () => {}; + }, [selectedSource, otherDetails]); return (
-

- How did you hear about us? +

+ How did you find us?

- Help us understand how people discover LLM Gateway (Optional) + Help us improve by letting us know how you found us. No pressure - you + can skip this step.

- - Referral Source - - + setOtherDetails(e.target.value)} className="w-full" />
)} + + {selectedSource && ( +
+ {selectedSource === "other" && !otherDetails.trim() + ? "Please provide details above, or use the Next button to continue." + : "Thanks! Moving to the next step automatically..."} +
+ )}
- -
- - -
); } diff --git a/apps/ui/src/components/onboarding/welcome-step.tsx b/apps/ui/src/components/onboarding/welcome-step.tsx index 732c304c5..11584251c 100644 --- a/apps/ui/src/components/onboarding/welcome-step.tsx +++ b/apps/ui/src/components/onboarding/welcome-step.tsx @@ -1,80 +1,314 @@ -import { Rocket } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Copy, Key, KeyIcon } from "lucide-react"; +import { useRouter } from "next/navigation"; import * as React from "react"; +import { useState, useEffect, useCallback } from "react"; +import { useDefaultProject } from "@/hooks/useDefaultProject"; import { useAuth } from "@/lib/auth-client"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/lib/components/card"; +import { Button } from "@/lib/components/button"; import { Step } from "@/lib/components/stepper"; +import { toast } from "@/lib/components/use-toast"; +import { useAppConfig } from "@/lib/config"; +import { useApi } from "@/lib/fetch-client"; export function WelcomeStep() { const { useSession } = useAuth(); const session = useSession(); const user = session.data?.user; - const organization = { name: "Your Organization" }; + const { data: defaultProject } = useDefaultProject(); + const api = useApi(); + const queryClient = useQueryClient(); + const router = useRouter(); + const [apiKey, setApiKey] = useState(null); + const config = useAppConfig(); + const isLocalhost = !config.hosted; + + const createApiKeyMutation = api.useMutation("post", "/keys/api"); + const completeOnboarding = api.useMutation( + "post", + "/user/me/complete-onboarding", + ); + + // Fetch existing API keys to check if one already exists + const { data: existingKeys, refetch: refetchKeys } = api.useQuery( + "get", + "/keys/api", + { + params: { + query: { projectId: defaultProject?.id || "" }, + }, + }, + { + enabled: !!defaultProject?.id && !!user, // Only fetch if user is authenticated + staleTime: 0, // Always fetch fresh data + refetchOnWindowFocus: true, + refetchOnMount: true, + }, + ); + + // Refetch API keys when component mounts to get fresh data + useEffect(() => { + if (defaultProject?.id && user) { + refetchKeys(); + } + }, [defaultProject?.id, user, refetchKeys]); + + // Also invalidate cache on mount to ensure fresh data + useEffect(() => { + if (defaultProject?.id) { + const queryKey = api.queryOptions("get", "/keys/api", { + params: { + query: { projectId: defaultProject.id }, + }, + }).queryKey; + queryClient.invalidateQueries({ queryKey }); + } + }, [defaultProject?.id, api, queryClient]); + + const createApiKey = useCallback(async () => { + // Security: Multiple authentication checks + if (!user) { + console.error("Unauthorized: No user session"); + toast({ + title: "Authentication Error", + description: "Please sign in to create API keys.", + variant: "destructive", + }); + return; + } + + if (!defaultProject?.id) { + console.error("Unauthorized: No project access"); + toast({ + title: "Access Error", + description: "No project found. Please contact support.", + variant: "destructive", + }); + return; + } + + try { + const response = await createApiKeyMutation.mutateAsync({ + body: { + description: "GATEWAY-001", + projectId: defaultProject.id, + usageLimit: null, + }, + }); + setApiKey(response.apiKey.token); + + // Invalidate API keys cache to ensure other components see the new key + const queryKey = api.queryOptions("get", "/keys/api", { + params: { + query: { projectId: defaultProject.id }, + }, + }).queryKey; + queryClient.invalidateQueries({ queryKey }); + } catch (error) { + console.error("Failed to create API key:", error); + toast({ + title: "Error", + description: + "Failed to create API key. We'll help you create one in the next step.", + variant: "destructive", + }); + } + }, [user, defaultProject?.id, createApiKeyMutation, api, queryClient]); + + // Auto-create API key on component mount only if no existing keys + useEffect(() => { + // Security: Only proceed if user is authenticated + if (!user) { + return; + } + + // Don't create keys if we're still loading existing keys data + if (existingKeys === undefined) { + return; + } + + const hasExistingKeys = + existingKeys?.apiKeys && existingKeys.apiKeys.length > 0; + const shouldCreateKey = + defaultProject?.id && + !apiKey && + !createApiKeyMutation.isPending && + !hasExistingKeys; + + if (shouldCreateKey) { + createApiKey(); + } + }, [ + user, + defaultProject?.id, + apiKey, + createApiKeyMutation.isPending, + createApiKey, + existingKeys, + ]); + + const copyToClipboard = () => { + if (apiKey) { + navigator.clipboard.writeText(apiKey); + toast({ + title: "Copied to clipboard", + description: "API key copied to clipboard", + }); + } + }; + + // Security: Ensure user is authenticated before proceeding + if (!user) { + return ( + +
+

Authentication Required

+

+ Please sign in to continue with the onboarding process. +

+
+
+ ); + } return ( -
-
-

Welcome to LLM Gateway!

-

- Let's get you set up with everything you need to start using the - platform. +

+ {/* Hero Section */} +
+

+ Welcome to LLM Gateway +

+

+ {isLocalhost + ? `You're all set up for self-hosting! ${!existingKeys?.apiKeys?.length ? "Let's get you connected to the platform with your first API key." : ""}` + : `${existingKeys?.apiKeys?.length ? "You can skip this step and go to the dashboard to manage your API keys." : "Let's get you connected to the platform with your first API key."}`}

- - - - - Your Project is Ready - - - We've automatically created a project for you to get started. - - - -
-
-

User

-

{user?.name}

-
-
-

Email

-

{user?.email}

+ {/* API Key Status Card/Alert */} + {(apiKey || + createApiKeyMutation.isPending || + (existingKeys?.apiKeys && existingKeys.apiKeys.length > 0)) && ( +
0) + ? "border-green-200 bg-gradient-to-br from-green-50 to-emerald-50 dark:border-green-800 dark:from-green-950 dark:to-emerald-950" + : "border-blue-200 bg-gradient-to-br from-blue-50 to-indigo-50 dark:border-blue-800 dark:from-blue-950 dark:to-indigo-950" + }`} + > +
+
0) + ? "bg-green-100 dark:bg-green-900" + : "bg-blue-100 dark:bg-blue-900" + }`} + > + {apiKey || + (existingKeys?.apiKeys && existingKeys.apiKeys.length > 0) ? ( + + ) : ( + + )}
-
-

Organization

-

- {organization?.name} -

-
-
-

Project

-

Default Project

+
+

+ {apiKey + ? "Your API key is ready!" + : existingKeys?.apiKeys && existingKeys.apiKeys.length > 0 + ? "You can already access models through LLM Gateway" + : "Creating your API key..."} +

+ + {apiKey ? ( +
+
+
+ + {apiKey || "Generating API key..."} + + +
+

+ Copy and store this key securely. You won't be able to + see it again. Check the docs on how to use it. +

+
+
+ ) : existingKeys?.apiKeys && existingKeys.apiKeys.length > 0 ? ( +
+

+ You have {existingKeys.apiKeys.length} API key + {existingKeys.apiKeys.length > 1 ? "s" : ""} configured + and ready to use. You can manage{" "} + {existingKeys.apiKeys.length > 1 ? "them" : "it"} in the + dashboard. +

+ {defaultProject && ( +

+ Keys are counted from your default project:{" "} + + {defaultProject.name} + +

+ )} +
+ ) : ( +

+ Setting up your first API key to get started... +

+ )}
+
+ )} -
-

- In this onboarding process, we'll help you: -

-
    -
  • Create your first API key to access the LLM Gateway
  • -
  • - Choose between buying credits or bringing your own API keys -
  • -
  • Set up your preferred payment method or provider keys
  • -
  • Get you ready to start making requests
  • -
+ {/* Skip Setup Option for Existing Users */} + {existingKeys?.apiKeys && + existingKeys.apiKeys.length > 1 && + !apiKey && ( +
+
- - + )}
); diff --git a/apps/ui/src/lib/components/stepper.tsx b/apps/ui/src/lib/components/stepper.tsx index 3c86b108d..51e4defc3 100644 --- a/apps/ui/src/lib/components/stepper.tsx +++ b/apps/ui/src/lib/components/stepper.tsx @@ -36,7 +36,7 @@ export function Stepper({
{/* Desktop stepper - show full horizontal layout */} -
+
{steps.map((step, index) => { @@ -75,11 +75,6 @@ export function Stepper({ {step.title} - {step.optional && ( - - (Optional) - - )}
); })} @@ -130,9 +125,16 @@ export function Stepper({
-
{children}
+
+ {children} +
-
+