auth aftercallback changes and logical restructure (#1668)
Closes https://linear.app/codeflash-ai/issue/CF-674/fix-login-flow-in-webapp
This commit is contained in:
parent
a2896e5680
commit
378412bd18
77 changed files with 3248 additions and 879 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -15,7 +15,7 @@ cli/dist/
|
|||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
|
|
|
|||
|
|
@ -1,12 +1,27 @@
|
|||
/** @type {import("next").NextConfig} */
|
||||
let nextConfig = {
|
||||
transpilePackages: ["@codeflash-ai/common"],
|
||||
experimental: { serverActions: { allowedOrigins: ["app.codeflash.ai", "localhost:3000"] } },
|
||||
experimental: {
|
||||
serverActions: { allowedOrigins: ["app.codeflash.ai", "localhost:3000"] },
|
||||
instrumentationHook: true,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: false,
|
||||
},
|
||||
// Optimize for production stability
|
||||
poweredByHeader: false,
|
||||
compress: true,
|
||||
images: {
|
||||
domains: ["avatars.githubusercontent.com", "github.com"],
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "avatars.githubusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "github.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -32,9 +47,6 @@ export default withSentryConfig(
|
|||
// Upload a larger set of source maps for prettier stack traces (increases build time)
|
||||
widenClientFileUpload: true,
|
||||
|
||||
// Transpiles SDK to be compatible with IE11 (increases bundle size)
|
||||
transpileClientSDK: true,
|
||||
|
||||
// Routes browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers (increases server load)
|
||||
tunnelRoute: "/monitoring",
|
||||
|
||||
|
|
@ -44,10 +56,7 @@ export default withSentryConfig(
|
|||
// Automatically tree-shake Sentry logger statements to reduce bundle size
|
||||
disableLogger: true,
|
||||
|
||||
// Enables automatic instrumentation of Vercel Cron Monitors.
|
||||
// See the following for more information:
|
||||
// https://docs.sentry.io/product/crons/
|
||||
// https://vercel.com/docs/cron-jobs
|
||||
automaticVercelMonitors: true,
|
||||
// Disable automatic instrumentation that might cause issues
|
||||
automaticVercelMonitors: false,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
2380
js/cf-webapp/package-lock.json
generated
2380
js/cf-webapp/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -23,7 +23,7 @@
|
|||
"@codeflash-ai/common": "^1.0.15",
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@prisma/client": "^6.2.1",
|
||||
"@prisma/client": "^6.7.0",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@sentry/nextjs": "^8.55.0",
|
||||
"@sentry/nextjs": "^9.34.0",
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.10.9",
|
||||
"@types/react": "^18",
|
||||
|
|
@ -60,6 +60,7 @@
|
|||
"react-markdown": "^9.0.1",
|
||||
"react-papaparse": "^4.4.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sharp": "^0.34.2",
|
||||
"stripe": "^17.7.0",
|
||||
"tailwind-merge": "^2.0.0",
|
||||
"tailwindcss": "^3.3.0",
|
||||
|
|
@ -83,7 +84,7 @@
|
|||
"jsdom": "^24.1.0",
|
||||
"lint-staged": "^15.4.3",
|
||||
"prettier": "3.2.5",
|
||||
"prisma": "^6.2.1",
|
||||
"prisma": "^6.7.0",
|
||||
"simple-git-hooks": "^2.9.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "^3.0.8"
|
||||
|
|
|
|||
52
js/cf-webapp/src/app/(auth)/login/page.tsx
Normal file
52
js/cf-webapp/src/app/(auth)/login/page.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { redirect } from "next/navigation"
|
||||
import { getSession } from "@auth0/nextjs-auth0"
|
||||
import { type JSX } from "react"
|
||||
|
||||
// Security function to validate returnTo URLs
|
||||
function isValidReturnUrl(url: string): boolean {
|
||||
if (url.startsWith("/")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export default async function AuthenticationPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { returnTo?: string; error?: string }
|
||||
}): Promise<JSX.Element> {
|
||||
const session = await getSession()
|
||||
|
||||
if (session) {
|
||||
// User is already logged in
|
||||
console.log(`[Login] User ${session.user.sub} already authenticated`)
|
||||
const returnTo =
|
||||
searchParams.returnTo && isValidReturnUrl(searchParams.returnTo) ? searchParams.returnTo : "/"
|
||||
redirect(returnTo)
|
||||
}
|
||||
|
||||
// Show error if there was one
|
||||
if (searchParams.error) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold">Login Error</h2>
|
||||
<p className="mt-2">There was an error during login. Please try again.</p>
|
||||
<a
|
||||
href="/api/auth/login"
|
||||
className="mt-4 inline-block rounded bg-blue-500 px-4 py-2 text-white"
|
||||
>
|
||||
Try Again
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Not logged in - redirect to Auth0
|
||||
const returnTo =
|
||||
searchParams.returnTo && isValidReturnUrl(searchParams.returnTo) ? searchParams.returnTo : "/"
|
||||
|
||||
const loginUrl = `/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`
|
||||
redirect(loginUrl)
|
||||
}
|
||||
|
|
@ -4,8 +4,8 @@ import LogoText from "./assets/LogoText"
|
|||
import { useEffect, useState } from "react"
|
||||
import { SubmitSecondOnboardingPage } from "./SubmitSecondOnboardingPage"
|
||||
import { useRouter } from "next/navigation"
|
||||
import CfRadioButton from "@/app/onboarding/CfRadioButton"
|
||||
import FinishSection from "@/app/onboarding/FinishSection"
|
||||
import CfRadioButton from "./CfRadioButton"
|
||||
import FinishSection from "./FinishSection"
|
||||
import PythonLibrariesInput from "./PythonLibrariesInput"
|
||||
|
||||
export default function SecondPage({ className = "" }: SecondPageProps) {
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { getSession } from "@auth0/nextjs-auth0"
|
||||
import { markUserCompletedOnboarding, submitOnboardingQuestions } from "@codeflash-ai/common"
|
||||
import PostHogClient from "@/app/app/posthog"
|
||||
import PostHogClient from "@/lib/posthog"
|
||||
import { redirect } from "next/navigation"
|
||||
import { cookies } from "next/headers"
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use server"
|
||||
|
||||
import { getSession } from "@auth0/nextjs-auth0"
|
||||
import PostHogClient from "@/app/app/posthog"
|
||||
import PostHogClient from "@/lib/posthog"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export async function SubmitSecondOnboardingPage(
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { getUserId } from "../utils/auth"
|
||||
import { getUserId } from "@/app/utils/auth"
|
||||
import { checkUserOnboardingStatus, completeUserOnboarding } from "./onboarding-action"
|
||||
import { upsertReferralSource, getUserSelectedReferral } from "./referral/actions"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
|
|
@ -26,7 +26,7 @@ import {
|
|||
} from "lucide-react"
|
||||
import LogoBox from "@/components/dashboard/logo-box"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { generateToken } from "../app/apikeys/tokenfuncs"
|
||||
import { generateToken } from "@/app/(dashboard)/apikeys/tokenfuncs"
|
||||
import { ApiKeyCard, StepInstruction } from "@/components/onboarding/step-content-renderer"
|
||||
|
||||
// Type definitions to improve type safety
|
||||
|
|
@ -5,7 +5,7 @@ import {
|
|||
isUserReferralComplete,
|
||||
getUserReferralData,
|
||||
} from "@codeflash-ai/common"
|
||||
import PostHogClient from "@/app/app/posthog"
|
||||
import PostHogClient from "@/lib/posthog"
|
||||
import { getSession } from "@auth0/nextjs-auth0"
|
||||
|
||||
export async function upsertReferralSource(
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import SecondPage from "@/app/onboarding/SecondPage"
|
||||
import SecondPage from "../SecondPage"
|
||||
import OnboardingWrapper from "../OnboardingWrapper"
|
||||
import "../compiled-styles.css"
|
||||
|
||||
|
|
@ -92,16 +92,30 @@ export async function getAllRepositories(
|
|||
|
||||
// New wrapper functions that call the imported functions
|
||||
export async function getUserOptimizationCount(userId: string, username: string) {
|
||||
return getOptimizationEventCountByUserId(userId, username)
|
||||
try {
|
||||
return await getOptimizationEventCountByUserId(userId, username)
|
||||
} catch (error) {
|
||||
console.error("Failed to get user optimization count:", error)
|
||||
throw new Error(
|
||||
`Failed to fetch optimization count: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserOptimizationSuccessfulCount(userId: string, username: string) {
|
||||
return prisma.optimization_events.count({
|
||||
where: {
|
||||
is_optimization_found: true,
|
||||
OR: [{ user_id: userId }, { current_username: username }],
|
||||
},
|
||||
})
|
||||
try {
|
||||
return await prisma.optimization_events.count({
|
||||
where: {
|
||||
is_optimization_found: true,
|
||||
OR: [{ user_id: userId }, { current_username: username }],
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to get successful optimization count:", error)
|
||||
throw new Error(
|
||||
`Failed to fetch successful optimization count: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRepoUserOptimizationCount(
|
||||
|
|
@ -113,7 +127,14 @@ export async function getRepoUserOptimizationCount(
|
|||
}
|
||||
|
||||
export async function getActiveRepositoriesLast30Days(userId: string, username: string) {
|
||||
return getActiveRepoCountLast30Days(userId, username)
|
||||
try {
|
||||
return await getActiveRepoCountLast30Days(userId, username)
|
||||
} catch (error) {
|
||||
console.error("Failed to get active repositories count:", error)
|
||||
throw new Error(
|
||||
`Failed to fetch active repositories: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOptimizationsTimeSeriesData(
|
||||
|
|
@ -122,6 +143,9 @@ export async function getOptimizationsTimeSeriesData(
|
|||
onlySuccessful?: boolean,
|
||||
) {
|
||||
try {
|
||||
if (!userId || !username) {
|
||||
throw new Error("Invalid user credentials")
|
||||
}
|
||||
const data = await prisma.optimization_events.findMany({
|
||||
where: {
|
||||
OR: [{ user_id: userId }, { current_username: username }],
|
||||
|
|
@ -170,7 +194,9 @@ export async function getOptimizationsTimeSeriesData(
|
|||
return completeData
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch optimization time series data:", error)
|
||||
return []
|
||||
throw new Error(
|
||||
`Failed to fetch time series data: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -180,6 +206,9 @@ export async function getPullRequestEventTimeSeriesData(
|
|||
year: number,
|
||||
) {
|
||||
try {
|
||||
if (!userId || !username || !year) {
|
||||
throw new Error("Invalid parameters provided")
|
||||
}
|
||||
const eventTypes = ["pr_created", "pr_merged", "pr_closed"]
|
||||
const data = await prisma.optimization_events.findMany({
|
||||
where: {
|
||||
|
|
@ -222,7 +251,9 @@ export async function getPullRequestEventTimeSeriesData(
|
|||
return completeData
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch pull request event time series data:", error)
|
||||
return []
|
||||
throw new Error(
|
||||
`Failed to fetch PR time series data: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -230,76 +261,87 @@ export async function getActiveUserLeaderboardLast30Days(
|
|||
userId: string,
|
||||
username: string,
|
||||
): Promise<{ username: string; eventCount: number; avatarUrl: string }[]> {
|
||||
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||||
try {
|
||||
if (!userId || !username) {
|
||||
throw new Error("Invalid user credentials")
|
||||
}
|
||||
|
||||
// Get the repository IDs the user has access to or has optimization events in
|
||||
const memberRepos = await prisma.repositories.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
repository_members: {
|
||||
some: {
|
||||
user_id: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
is_private: false,
|
||||
optimization_events: {
|
||||
some: {
|
||||
current_username: username,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
const repoIds = memberRepos.map(r => r.id)
|
||||
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
if (repoIds.length === 0) return []
|
||||
|
||||
const groupedCounts = await prisma.optimization_events.groupBy({
|
||||
by: ["current_username"],
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ user_id: userId },
|
||||
{
|
||||
repository_id: {
|
||||
in: repoIds,
|
||||
// Get the repository IDs the user has access to or has optimization events in
|
||||
const memberRepos = await prisma.repositories.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
repository_members: {
|
||||
some: {
|
||||
user_id: userId,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
created_at: {
|
||||
gte: since,
|
||||
},
|
||||
},
|
||||
{
|
||||
current_username: {
|
||||
not: null,
|
||||
{
|
||||
is_private: false,
|
||||
optimization_events: {
|
||||
some: {
|
||||
current_username: username,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
orderBy: {
|
||||
_count: {
|
||||
id: "desc",
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
const repoIds = memberRepos.map(r => r.id)
|
||||
|
||||
return groupedCounts.map(entry => ({
|
||||
username: entry.current_username!,
|
||||
eventCount: entry._count.id,
|
||||
avatarUrl: `https://github.com/${entry.current_username}.png`,
|
||||
}))
|
||||
if (repoIds.length === 0) return []
|
||||
|
||||
const groupedCounts = await prisma.optimization_events.groupBy({
|
||||
by: ["current_username"],
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ user_id: userId },
|
||||
{
|
||||
repository_id: {
|
||||
in: repoIds,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
created_at: {
|
||||
gte: since,
|
||||
},
|
||||
},
|
||||
{
|
||||
current_username: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
orderBy: {
|
||||
_count: {
|
||||
id: "desc",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return groupedCounts.map(entry => ({
|
||||
username: entry.current_username!,
|
||||
eventCount: entry._count.id,
|
||||
avatarUrl: `https://github.com/${entry.current_username}.png`,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch active user leaderboard:", error)
|
||||
throw new Error(
|
||||
`Failed to fetch leaderboard data: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import {
|
|||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import { DeleteApiKeyButton } from "@/app/app/apikeys/delete-api-key-button"
|
||||
import { DeleteApiKeyButton } from "./delete-api-key-button"
|
||||
import React, { type JSX } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -22,7 +22,7 @@ import {
|
|||
import { Label } from "@/components/ui/label"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { type cf_api_keys } from "@prisma/client"
|
||||
import { deleteAPIKey } from "@/app/app/apikeys/tokenfuncs"
|
||||
import { deleteAPIKey } from "./tokenfuncs"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
|
||||
export function ApiKeyTable({ apiKeys }: { apiKeys: cf_api_keys[] }): JSX.Element {
|
||||
|
|
@ -19,7 +19,7 @@ import * as z from "zod"
|
|||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import React from "react"
|
||||
import { generateToken } from "@/app/app/apikeys/tokenfuncs"
|
||||
import { generateToken } from "./tokenfuncs"
|
||||
import { Plus } from "lucide-react"
|
||||
|
||||
const formSchema = z.object({
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
"use server"
|
||||
import { getSession } from "@auth0/nextjs-auth0"
|
||||
import { redirect } from "next/navigation"
|
||||
import { CreateApiKeyDialog } from "@/app/app/apikeys/dialog-create-api-key"
|
||||
import { CreateApiKeyDialog } from "./dialog-create-api-key"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { ApiKeyTable } from "@/app/app/apikeys/api-key-table"
|
||||
import { ApiKeyTable } from "./api-key-table"
|
||||
import { type cf_api_keys, PrismaClient } from "@prisma/client"
|
||||
import PostHogClient from "@/app/app/posthog"
|
||||
import PostHogClient from "@/lib/posthog"
|
||||
import { ApiKeysClient } from "./api-keys-client"
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
|
@ -3,7 +3,7 @@ import { getSession } from "@auth0/nextjs-auth0"
|
|||
import { redirect } from "next/navigation"
|
||||
import { PrismaClient } from "@prisma/client"
|
||||
import { BillingView } from "./billing-view"
|
||||
import PostHogClient from "@/app/app/posthog"
|
||||
import PostHogClient from "@/lib/posthog"
|
||||
import { SUBSCRIPTION_PLANS } from "@codeflash-ai/common"
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import OnboardingFlow from "@/components/onboarding/onboarding-flow"
|
||||
import INTEGRATION_CONFIG from "@/app/onboarding/config"
|
||||
import INTEGRATION_CONFIG from "@/app/(auth)/onboarding/config"
|
||||
import { CompletionDialog } from "@/components/onboarding/completion-dialog"
|
||||
import { ConfettiEffect } from "@/components/onboarding/confetti-effect"
|
||||
|
||||
24
js/cf-webapp/src/app/(dashboard)/getting-started/page.tsx
Normal file
24
js/cf-webapp/src/app/(dashboard)/getting-started/page.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { getSession } from "@auth0/nextjs-auth0"
|
||||
import { redirect } from "next/navigation"
|
||||
import PostHogClient from "@/lib/posthog"
|
||||
import GettingStartedClient from "./getting-started-client"
|
||||
|
||||
export default async function GettingStarted() {
|
||||
const session = await getSession()
|
||||
if (session == null) {
|
||||
redirect("/login")
|
||||
}
|
||||
|
||||
// Server-side tracking
|
||||
const userId = session.user.sub
|
||||
const posthog = PostHogClient()
|
||||
posthog.capture({
|
||||
distinctId: userId,
|
||||
properties: { username: session.nickname },
|
||||
event: "webapp-loaded-getting-started",
|
||||
})
|
||||
|
||||
await posthog.shutdown()
|
||||
|
||||
return <GettingStartedClient />
|
||||
}
|
||||
32
js/cf-webapp/src/app/(dashboard)/layout.tsx
Normal file
32
js/cf-webapp/src/app/(dashboard)/layout.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { getSession } from "@auth0/nextjs-auth0"
|
||||
import { redirect } from "next/navigation"
|
||||
import { ReactNode } from "react"
|
||||
import { hasCompletedOnboarding } from "@codeflash-ai/common"
|
||||
import { Sidebar } from "@/components/dashboard/sidebar"
|
||||
|
||||
/**
|
||||
* Dashboard layout with authentication and sidebar
|
||||
* Applied to all routes in the (dashboard) group
|
||||
*/
|
||||
export default async function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session) {
|
||||
redirect("/login")
|
||||
}
|
||||
|
||||
const completedOnboarding = await hasCompletedOnboarding(session.user.sub)
|
||||
if (!completedOnboarding) {
|
||||
redirect("/onboarding")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar
|
||||
className="h-screen border-r border-border/30 flex-shrink-0 w-60 bg-background"
|
||||
user={session.user}
|
||||
/>
|
||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -15,7 +15,7 @@ import {
|
|||
import Image from "next/image"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { getUserIdAndUsername } from "@/app/utils/auth"
|
||||
import { RepositoryWithUsage, getAllRepositories } from "../home/action"
|
||||
import { RepositoryWithUsage, getAllRepositories } from "../action"
|
||||
|
||||
// Error Boundary Component
|
||||
class RepositoryErrorBoundary extends React.Component<
|
||||
|
|
@ -646,7 +646,7 @@ function RepositoriesPage() {
|
|||
|
||||
// Update last auth check time
|
||||
localStorage.setItem("lastAuthCheck", now.toString())
|
||||
}, [])
|
||||
}, [fetchRepositories])
|
||||
|
||||
// Refresh Button Component
|
||||
const RefreshButton = () => (
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
// app/api/auth/[auth0]/route.js
|
||||
|
||||
import {
|
||||
type AfterCallbackAppRoute,
|
||||
type AppRouteHandlerFnContext,
|
||||
|
|
@ -11,26 +9,89 @@ import {
|
|||
type Session,
|
||||
} from "@auth0/nextjs-auth0"
|
||||
import { type NextRequest, NextResponse } from "next/server"
|
||||
import { createOrUpdateUser, hasCompletedOnboarding } from "@codeflash-ai/common"
|
||||
import { trackUserLogin } from "@/lib/analytics/tracking"
|
||||
import { cookies } from "next/headers"
|
||||
|
||||
const afterCallback: AfterCallbackAppRoute = (req: NextRequest, session: Session) => {
|
||||
if (session.user != null) {
|
||||
// THIS IS THE KEY CHANGE - Your afterCallback was empty!
|
||||
const afterCallback: AfterCallbackAppRoute = async (req: NextRequest, session: Session) => {
|
||||
if (!session.user) {
|
||||
return session
|
||||
}
|
||||
|
||||
const user = session.user
|
||||
console.log(`[Auth] Processing login for user: ${user.sub}`)
|
||||
|
||||
if (!user.sub || !user.nickname) {
|
||||
console.error("[Auth] Missing required user fields")
|
||||
return session
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. SAVE TO DATABASE (moved from login page!)
|
||||
console.log("[Auth] Saving user to database...")
|
||||
await createOrUpdateUser(user.sub, user.nickname, user.email ?? null, user.name ?? null)
|
||||
console.log("[Auth] User saved successfully")
|
||||
|
||||
// 2. TRACK LOGIN (moved from login page!)
|
||||
await trackUserLogin({
|
||||
userId: user.sub,
|
||||
username: user.nickname,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
})
|
||||
|
||||
// 3. CHECK ONBOARDING (moved from login page!)
|
||||
const completedOnboarding = await hasCompletedOnboarding(user.sub)
|
||||
console.log(`[Auth] Onboarding completed: ${completedOnboarding}`)
|
||||
|
||||
// 4. Decide where to redirect
|
||||
let intendedDestination = "/dashboard"
|
||||
|
||||
// Try to get returnTo from state
|
||||
const url = new URL(req.url)
|
||||
const stateParam = url.searchParams.get("state")
|
||||
if (stateParam) {
|
||||
try {
|
||||
const state = JSON.parse(Buffer.from(stateParam, "base64").toString("utf-8"))
|
||||
if (state.returnTo) {
|
||||
intendedDestination = state.returnTo
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[Auth] Failed to parse state")
|
||||
}
|
||||
}
|
||||
|
||||
// Handle onboarding redirect
|
||||
if (!completedOnboarding) {
|
||||
session.returnTo = "/onboarding"
|
||||
} else {
|
||||
session.returnTo = intendedDestination
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Auth] Error in afterCallback:", error)
|
||||
// Don't fail login even if our processing fails
|
||||
session.returnTo = "/dashboard"
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
// Rest of your file stays mostly the same...
|
||||
export const GET = handleAuth({
|
||||
// eslint-disable-next-line
|
||||
// Your existing login handler...
|
||||
login: async (request: any, response: any) => {
|
||||
console.log("Logging in")
|
||||
try {
|
||||
return await handleLogin(request as NextRequest, response as AppRouteHandlerFnContext, {
|
||||
returnTo: "/app",
|
||||
returnTo: "/dashboard",
|
||||
})
|
||||
} catch (error) {
|
||||
console.log("Error logging in:", error)
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line
|
||||
|
||||
// Your existing logout handler...
|
||||
logout: async (request: any, response: any) => {
|
||||
console.log("Logging out")
|
||||
try {
|
||||
|
|
@ -41,40 +102,29 @@ export const GET = handleAuth({
|
|||
console.log("Error logging out:", error)
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line
|
||||
|
||||
// Updated callback handler
|
||||
callback: async (req: any, res: any) => {
|
||||
try {
|
||||
const response = (await handleCallback(req as NextRequest, res as AppRouteHandlerFnContext, {
|
||||
afterCallback,
|
||||
afterCallback, // NOW THIS DOES SOMETHING!
|
||||
})) as NextResponse
|
||||
|
||||
const session = await getSession(req as NextRequest, response)
|
||||
// Get returnTo URL from the state if available
|
||||
const url = new URL(req.url)
|
||||
const stateParam = url.searchParams.get("state")
|
||||
let returnTo = "/app"
|
||||
try {
|
||||
if (stateParam) {
|
||||
const state = JSON.parse(Buffer.from(stateParam, "base64").toString("utf-8"))
|
||||
if (state.returnTo) {
|
||||
returnTo = state.returnTo
|
||||
console.log(returnTo)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to parse returnTo from state", e)
|
||||
}
|
||||
|
||||
if (session != null) {
|
||||
// Use the returnTo set by afterCallback
|
||||
const returnTo = session.returnTo || "/dashboard"
|
||||
const isAbsolute = returnTo.includes(process.env.AUTH0_BASE_URL ?? "")
|
||||
const redirectUrl = isAbsolute ? returnTo : `${process.env.AUTH0_BASE_URL}${returnTo}`
|
||||
return NextResponse.redirect(redirectUrl, response)
|
||||
} else {
|
||||
// TODO: Unsure where this should redirect to
|
||||
return NextResponse.redirect(`${process.env.AUTH0_BASE_URL}/waitlist`, response)
|
||||
} // eslint-disable-next-line
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.log("Error in callback:", error)
|
||||
// Your existing error handling...
|
||||
if (error.status === 400 && error.message.search("allowlist-fail") !== -1) {
|
||||
// write regex parsing and group extraction for the following regex allowlist-fail\s(.*)\s(.*)\)
|
||||
const re = /allowlist-fail\s(.*)\s(.*)\)/
|
||||
const match = error.message.match(re)
|
||||
if (match != null) {
|
||||
|
|
@ -87,6 +137,4 @@ export const GET = handleAuth({
|
|||
}
|
||||
}
|
||||
},
|
||||
|
||||
// TODO: Add callback for the waitlist if someone is not on the allowlist
|
||||
})
|
||||
|
|
|
|||
10
js/cf-webapp/src/app/app/[...slug]/page.tsx
Normal file
10
js/cf-webapp/src/app/app/[...slug]/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { redirect } from "next/navigation"
|
||||
|
||||
/**
|
||||
* Catch-all route for legacy /app/* URLs
|
||||
* Redirects to the corresponding route without the /app prefix
|
||||
*/
|
||||
export default function LegacyAppCatchAll({ params }: { params: { slug: string[] } }) {
|
||||
const newPath = `/${params.slug.join("/")}`
|
||||
redirect(newPath)
|
||||
}
|
||||
|
|
@ -1,24 +1,9 @@
|
|||
import { getSession } from "@auth0/nextjs-auth0"
|
||||
import { redirect } from "next/navigation"
|
||||
import PostHogClient from "@/app/app/posthog"
|
||||
import GettingStartedClient from "./getting-started-client"
|
||||
|
||||
export default async function GettingStarted() {
|
||||
const session = await getSession()
|
||||
if (session == null) {
|
||||
redirect("/login")
|
||||
}
|
||||
|
||||
// Server-side tracking
|
||||
const userId = session.user.sub
|
||||
const posthog = PostHogClient()
|
||||
posthog.capture({
|
||||
distinctId: userId,
|
||||
properties: { username: session.nickname },
|
||||
event: "webapp-loaded-getting-started",
|
||||
})
|
||||
|
||||
await posthog.shutdown()
|
||||
|
||||
return <GettingStartedClient />
|
||||
/**
|
||||
* Legacy /app/gettingstarted route for external links compatibility
|
||||
* Redirects to the correct getting-started route
|
||||
*/
|
||||
export default function LegacyGettingStartedPage() {
|
||||
redirect("/getting-started")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
"use client"
|
||||
import { Sidebar } from "@/components/dashboard/sidebar"
|
||||
import { type JSX, useState, useEffect } from "react"
|
||||
import { useUser } from "@auth0/nextjs-auth0/client"
|
||||
|
||||
export default function AppRootLayout({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
const { user, error, isLoading } = useUser()
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [theme, setTheme] = useState<"light" | "dark">("light")
|
||||
|
||||
// Initialize theme from localStorage or system preference
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem("theme") as "light" | "dark" | null
|
||||
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme)
|
||||
document.documentElement.classList.toggle("dark", savedTheme === "dark")
|
||||
} else if (prefersDark) {
|
||||
setTheme("dark")
|
||||
document.documentElement.classList.add("dark")
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex bg-background text-foreground">
|
||||
<div className="max-w-xs">
|
||||
<Sidebar
|
||||
className="h-screen border-r border-border/30 fixed top-0 left-0 w-60"
|
||||
user={user!}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Page Content */}
|
||||
<div className="px-6 py-6 relative left-60 w-[calc(100%-240px)]">{children}</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,29 +1,18 @@
|
|||
"use client"
|
||||
import { redirect } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
import { usePostHog } from "posthog-js/react"
|
||||
import { useUser } from "@auth0/nextjs-auth0/client"
|
||||
import { getSession } from "@auth0/nextjs-auth0"
|
||||
|
||||
export default function AppHomePage(): void {
|
||||
const posthog = usePostHog()
|
||||
const session = useUser()
|
||||
useEffect(() => {
|
||||
if (session?.user?.sub) {
|
||||
posthog?.identify(session.user.sub, {
|
||||
username: session.user.nickname,
|
||||
name: session.user.name,
|
||||
email: session.user.email,
|
||||
})
|
||||
// posthog?.group("company", user.company_id)
|
||||
redirect("/app/apikeys")
|
||||
} else {
|
||||
redirect("/login")
|
||||
}
|
||||
}, [
|
||||
posthog,
|
||||
session?.user?.email,
|
||||
session?.user?.name,
|
||||
session?.user?.nickname,
|
||||
session?.user?.sub,
|
||||
])
|
||||
/**
|
||||
* Legacy /app route for external links compatibility
|
||||
* Redirects to the appropriate route based on authentication status
|
||||
*/
|
||||
export default async function LegacyAppPage() {
|
||||
const session = await getSession()
|
||||
|
||||
if (session) {
|
||||
// Authenticated users go to the dashboard (root)
|
||||
redirect("/")
|
||||
} else {
|
||||
// Non-authenticated users go to login
|
||||
redirect("/login")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
373
js/cf-webapp/src/app/dashboard/action.ts
Normal file
373
js/cf-webapp/src/app/dashboard/action.ts
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
"use server"
|
||||
|
||||
import {
|
||||
prisma,
|
||||
getActiveRepoCountLast30Days,
|
||||
getOptimizationEventCountByRepoAndUser,
|
||||
getOptimizationEventCountByUserId,
|
||||
} from "@codeflash-ai/common"
|
||||
import { eachDayOfInterval, startOfDay } from "date-fns"
|
||||
|
||||
export interface RepositoryWithUsage {
|
||||
id: string
|
||||
github_repo_id: string
|
||||
name: string
|
||||
full_name: string
|
||||
is_private: boolean
|
||||
is_active: boolean
|
||||
has_github_action: boolean
|
||||
created_at: Date
|
||||
last_optimized: Date | null
|
||||
optimizations_limit: number | null
|
||||
optimizations_used: number
|
||||
organization: string
|
||||
membersCount?: number // Count from repository_members
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
export async function getAllRepositories(
|
||||
userId: string,
|
||||
username: string,
|
||||
): Promise<RepositoryWithUsage[]> {
|
||||
try {
|
||||
// Validate inputs
|
||||
if (!userId || !username) {
|
||||
console.error("Invalid userId or username provided:", { userId, username })
|
||||
throw new Error("Authentication required: Invalid user credentials")
|
||||
}
|
||||
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const repositories = await prisma.repositories.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
repository_members: {
|
||||
some: {
|
||||
user_id: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
optimization_events: {
|
||||
some: {
|
||||
current_username: username,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// For each repo, check if it has any optimization event in the last 30 days
|
||||
const reposWithActiveFlag = await Promise.all(
|
||||
repositories.map(async repo => {
|
||||
try {
|
||||
const recentEventCount = await prisma.optimization_events.count({
|
||||
where: {
|
||||
repository_id: repo.id,
|
||||
created_at: {
|
||||
gte: thirtyDaysAgo,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: repo.id,
|
||||
github_repo_id: repo.github_repo_id,
|
||||
name: repo.name,
|
||||
full_name: repo.full_name,
|
||||
is_private: repo.is_private,
|
||||
is_active: recentEventCount > 0,
|
||||
has_github_action: repo.has_github_action,
|
||||
created_at: repo.created_at,
|
||||
last_optimized: repo.last_optimized,
|
||||
optimizations_limit: repo.optimizations_limit,
|
||||
optimizations_used: repo.optimizations_used,
|
||||
organization: repo.full_name.split("/")[0],
|
||||
avatarUrl: `https://github.com/${repo.full_name.split("/")[0]}.png`,
|
||||
}
|
||||
} catch (repoError) {
|
||||
console.error(`Failed to process repository ${repo.id}:`, repoError)
|
||||
// Return basic repo data on individual repo processing error
|
||||
return {
|
||||
id: repo.id,
|
||||
github_repo_id: repo.github_repo_id,
|
||||
name: repo.name,
|
||||
full_name: repo.full_name,
|
||||
is_private: repo.is_private,
|
||||
is_active: false,
|
||||
has_github_action: repo.has_github_action,
|
||||
created_at: repo.created_at,
|
||||
last_optimized: repo.last_optimized,
|
||||
optimizations_limit: repo.optimizations_limit,
|
||||
optimizations_used: repo.optimizations_used,
|
||||
organization: repo.full_name.split("/")[0],
|
||||
avatarUrl: `https://github.com/${repo.full_name.split("/")[0]}.png`,
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return reposWithActiveFlag
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch repositories:", error)
|
||||
throw new Error(`Database error: ${error instanceof Error ? error.message : "Unknown error"}`)
|
||||
}
|
||||
}
|
||||
|
||||
// New wrapper functions that call the imported functions
|
||||
export async function getUserOptimizationCount(userId: string, username: string) {
|
||||
try {
|
||||
return await getOptimizationEventCountByUserId(userId, username)
|
||||
} catch (error) {
|
||||
console.error("Failed to get user optimization count:", error)
|
||||
throw new Error(
|
||||
`Failed to fetch optimization count: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserOptimizationSuccessfulCount(userId: string, username: string) {
|
||||
try {
|
||||
return await prisma.optimization_events.count({
|
||||
where: {
|
||||
is_optimization_found: true,
|
||||
OR: [{ user_id: userId }, { current_username: username }],
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to get successful optimization count:", error)
|
||||
throw new Error(
|
||||
`Failed to fetch successful optimization count: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRepoUserOptimizationCount(
|
||||
repoId: string,
|
||||
userId: string,
|
||||
username: string,
|
||||
) {
|
||||
return getOptimizationEventCountByRepoAndUser(repoId, userId, username)
|
||||
}
|
||||
|
||||
export async function getActiveRepositoriesLast30Days(userId: string, username: string) {
|
||||
try {
|
||||
return await getActiveRepoCountLast30Days(userId, username)
|
||||
} catch (error) {
|
||||
console.error("Failed to get active repositories count:", error)
|
||||
throw new Error(
|
||||
`Failed to fetch active repositories: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOptimizationsTimeSeriesData(
|
||||
userId: string,
|
||||
username: string,
|
||||
onlySuccessful?: boolean,
|
||||
) {
|
||||
try {
|
||||
if (!userId || !username) {
|
||||
throw new Error("Invalid user credentials")
|
||||
}
|
||||
const data = await prisma.optimization_events.findMany({
|
||||
where: {
|
||||
OR: [{ user_id: userId }, { current_username: username }],
|
||||
...(onlySuccessful === true ? { is_optimization_found: true } : {}),
|
||||
},
|
||||
select: {
|
||||
created_at: true,
|
||||
},
|
||||
})
|
||||
|
||||
const groupedByDay: Record<string, number> = {}
|
||||
|
||||
data.forEach(item => {
|
||||
// Use the user's local time zone to format the date as YYYY-MM-DD
|
||||
const day = item.created_at
|
||||
.toLocaleDateString(undefined, {
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
})
|
||||
.replace(/(\d{2})\/(\d{2})\/(\d{4})/, "$3-$1-$2") // Convert MM/DD/YYYY to YYYY-MM-DD
|
||||
groupedByDay[day] = (groupedByDay[day] || 0) + 1
|
||||
})
|
||||
|
||||
const allDates = eachDayOfInterval({
|
||||
start: new Date(Object.keys(groupedByDay).sort()[0]),
|
||||
end: startOfDay(new Date()),
|
||||
}).map(d =>
|
||||
d
|
||||
.toLocaleDateString(undefined, {
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
})
|
||||
.replace(/(\d{2})\/(\d{2})\/(\d{4})/, "$3-$1-$2"),
|
||||
)
|
||||
|
||||
let cumulativeCount = 0
|
||||
const completeData = allDates.map(date => {
|
||||
cumulativeCount += groupedByDay[date] || 0
|
||||
return { date, count: cumulativeCount }
|
||||
})
|
||||
|
||||
return completeData
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch optimization time series data:", error)
|
||||
throw new Error(
|
||||
`Failed to fetch time series data: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPullRequestEventTimeSeriesData(
|
||||
userId: string,
|
||||
username: string,
|
||||
year: number,
|
||||
) {
|
||||
try {
|
||||
if (!userId || !username || !year) {
|
||||
throw new Error("Invalid parameters provided")
|
||||
}
|
||||
const eventTypes = ["pr_created", "pr_merged", "pr_closed"]
|
||||
const data = await prisma.optimization_events.findMany({
|
||||
where: {
|
||||
OR: [{ user_id: userId }, { current_username: username }],
|
||||
event_type: { in: eventTypes },
|
||||
created_at: {
|
||||
gte: new Date(`${year}-01-01T00:00:00.000Z`),
|
||||
lt: new Date(`${year + 1}-01-01T00:00:00.000Z`),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
event_type: true,
|
||||
created_at: true,
|
||||
},
|
||||
})
|
||||
|
||||
const groupedByMonth: Record<string, Record<string, number>> = {}
|
||||
|
||||
// Initialize the months of the year
|
||||
for (let month = 1; month <= 12; month++) {
|
||||
const monthKey = `${year}-${month.toString().padStart(2, "0")}`
|
||||
groupedByMonth[monthKey] = { pr_created: 0, pr_merged: 0, pr_closed: 0 }
|
||||
}
|
||||
|
||||
data.forEach(item => {
|
||||
const month = item.created_at.getMonth() + 1 // JavaScript months are 0-indexed
|
||||
const monthKey = `${year}-${month.toString().padStart(2, "0")}`
|
||||
if (groupedByMonth[monthKey]) {
|
||||
groupedByMonth[monthKey][item.event_type] += 1
|
||||
}
|
||||
})
|
||||
|
||||
const completeData = Object.keys(groupedByMonth).map(monthKey => ({
|
||||
month: monthKey,
|
||||
pr_created: groupedByMonth[monthKey].pr_created,
|
||||
pr_merged: groupedByMonth[monthKey].pr_merged,
|
||||
pr_closed: groupedByMonth[monthKey].pr_closed,
|
||||
}))
|
||||
|
||||
return completeData
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch pull request event time series data:", error)
|
||||
throw new Error(
|
||||
`Failed to fetch PR time series data: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getActiveUserLeaderboardLast30Days(
|
||||
userId: string,
|
||||
username: string,
|
||||
): Promise<{ username: string; eventCount: number; avatarUrl: string }[]> {
|
||||
try {
|
||||
if (!userId || !username) {
|
||||
throw new Error("Invalid user credentials")
|
||||
}
|
||||
|
||||
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
// Get the repository IDs the user has access to or has optimization events in
|
||||
const memberRepos = await prisma.repositories.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
repository_members: {
|
||||
some: {
|
||||
user_id: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
is_private: false,
|
||||
optimization_events: {
|
||||
some: {
|
||||
current_username: username,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
const repoIds = memberRepos.map(r => r.id)
|
||||
|
||||
if (repoIds.length === 0) return []
|
||||
|
||||
const groupedCounts = await prisma.optimization_events.groupBy({
|
||||
by: ["current_username"],
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ user_id: userId },
|
||||
{
|
||||
repository_id: {
|
||||
in: repoIds,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
created_at: {
|
||||
gte: since,
|
||||
},
|
||||
},
|
||||
{
|
||||
current_username: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
orderBy: {
|
||||
_count: {
|
||||
id: "desc",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return groupedCounts.map(entry => ({
|
||||
username: entry.current_username!,
|
||||
eventCount: entry._count.id,
|
||||
avatarUrl: `https://github.com/${entry.current_username}.png`,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch active user leaderboard:", error)
|
||||
throw new Error(
|
||||
`Failed to fetch leaderboard data: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
32
js/cf-webapp/src/app/dashboard/layout.tsx
Normal file
32
js/cf-webapp/src/app/dashboard/layout.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { getSession } from "@auth0/nextjs-auth0"
|
||||
import { redirect } from "next/navigation"
|
||||
import { ReactNode } from "react"
|
||||
import { hasCompletedOnboarding } from "@codeflash-ai/common"
|
||||
import { Sidebar } from "@/components/dashboard/sidebar"
|
||||
|
||||
/**
|
||||
* Dashboard layout with authentication and sidebar
|
||||
* Applied to /dashboard route
|
||||
*/
|
||||
export default async function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session) {
|
||||
redirect("/login")
|
||||
}
|
||||
|
||||
const completedOnboarding = await hasCompletedOnboarding(session.user.sub)
|
||||
if (!completedOnboarding) {
|
||||
redirect("/onboarding")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar
|
||||
className="h-screen border-r border-border/30 flex-shrink-0 w-60 bg-background"
|
||||
user={session.user}
|
||||
/>
|
||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1106,28 +1106,8 @@ function Dashboard() {
|
|||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user was recently authenticated
|
||||
const lastAuthCheck = localStorage.getItem("lastAuthCheck")
|
||||
const now = Date.now()
|
||||
|
||||
// If last auth check was less than 2 seconds ago, wait a bit
|
||||
if (lastAuthCheck && now - parseInt(lastAuthCheck) < 2000) {
|
||||
const delay = 2000 - (now - parseInt(lastAuthCheck))
|
||||
setTimeout(() => {
|
||||
fetchDashboardData()
|
||||
}, delay)
|
||||
} else {
|
||||
// Add a small delay to prevent race conditions on rapid refreshes
|
||||
const timeoutId = setTimeout(() => {
|
||||
fetchDashboardData()
|
||||
}, 100)
|
||||
|
||||
const cleanup = () => clearTimeout(timeoutId)
|
||||
return cleanup
|
||||
}
|
||||
|
||||
// Update last auth check time
|
||||
localStorage.setItem("lastAuthCheck", now.toString())
|
||||
// Simple initialization without localStorage timing checks
|
||||
fetchDashboardData()
|
||||
}, [fetchDashboardData])
|
||||
|
||||
// Calculate metrics for repository summary with safety checks
|
||||
42
js/cf-webapp/src/app/error.tsx
Normal file
42
js/cf-webapp/src/app/error.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { RefreshCw } from "lucide-react"
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Log the error to Sentry
|
||||
console.error("Application Error:", error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="max-w-md w-full mx-auto p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-4 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<RefreshCw className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-red-800 mb-2">Something went wrong!</h2>
|
||||
<p className="text-red-600 mb-4 text-sm">
|
||||
{process.env.NODE_ENV === "development"
|
||||
? error.message
|
||||
: "An unexpected error occurred. Please try again."}
|
||||
</p>
|
||||
{error.digest && <p className="text-xs text-red-500 mb-4">Error ID: {error.digest}</p>}
|
||||
<button
|
||||
onClick={reset}
|
||||
className="w-full bg-red-100 hover:bg-red-200 text-red-800 font-medium py-2 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
48
js/cf-webapp/src/app/global-error.tsx
Normal file
48
js/cf-webapp/src/app/global-error.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { RefreshCw } from "lucide-react"
|
||||
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Log the error to Sentry
|
||||
console.error("Global Application Error:", error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="max-w-md w-full mx-auto p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-6 text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-4 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<RefreshCw className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-red-800 mb-2">Application Error</h2>
|
||||
<p className="text-red-600 mb-4 text-sm">
|
||||
{process.env.NODE_ENV === "development"
|
||||
? error.message
|
||||
: "A critical error occurred. Please refresh the page."}
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-xs text-red-500 mb-4">Error ID: {error.digest}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={reset}
|
||||
className="w-full bg-red-100 hover:bg-red-200 text-red-800 font-medium py-2 px-4 rounded-lg transition-colors"
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
"use server"
|
||||
import { redirect } from "next/navigation"
|
||||
import { getSession } from "@auth0/nextjs-auth0"
|
||||
import { type JSX } from "react"
|
||||
import { createOrUpdateUser, hasCompletedOnboarding } from "@codeflash-ai/common"
|
||||
import PostHogClient from "@/app/app/posthog"
|
||||
import { cookies } from "next/headers"
|
||||
|
||||
// export const metadata: Metadata = {
|
||||
// title: 'Codeflash login',
|
||||
// description: 'The login page for Codeflash webapp'
|
||||
// }
|
||||
// Security function to validate returnTo URLs
|
||||
function isValidReturnUrl(url: string): boolean {
|
||||
// Only allow relative URLs or your domains
|
||||
if (url.startsWith("/")) {
|
||||
return true
|
||||
}
|
||||
return false // Reject external URLs
|
||||
}
|
||||
|
||||
export default async function AuthenticationPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { returnTo?: string }
|
||||
}): Promise<JSX.Element> {
|
||||
const session = await getSession()
|
||||
|
||||
if (session == null) {
|
||||
const returnTo = searchParams.returnTo
|
||||
if (returnTo && isValidReturnUrl(returnTo)) {
|
||||
redirect(`/api/auth/login?returnTo=${encodeURIComponent(returnTo)}`)
|
||||
} else {
|
||||
redirect("/api/auth/login")
|
||||
}
|
||||
} else {
|
||||
const user = session.user
|
||||
console.log("user logged in!", user)
|
||||
if (!(user.sub && user.nickname)) {
|
||||
console.error("user.sub or user.nickname is null")
|
||||
} else {
|
||||
await createOrUpdateUser(user.sub, user.nickname, user.email ?? null, user.name ?? null)
|
||||
const posthog = PostHogClient()
|
||||
posthog.identify({
|
||||
distinctId: user.sub,
|
||||
properties: {
|
||||
username: user.nickname,
|
||||
email: user.email,
|
||||
name: session?.user?.name,
|
||||
},
|
||||
})
|
||||
posthog.capture({
|
||||
distinctId: user.sub,
|
||||
properties: { username: user.nickname, email: user.email },
|
||||
event: "webapp-user-logged-in",
|
||||
})
|
||||
await posthog.shutdown()
|
||||
}
|
||||
// Get and validate returnTo parameter
|
||||
const returnTo = searchParams.returnTo
|
||||
const safeReturnTo = returnTo && isValidReturnUrl(returnTo) ? returnTo : null
|
||||
|
||||
const completedOnboarding = await hasCompletedOnboarding(user.sub)
|
||||
if (!completedOnboarding) {
|
||||
// Save the returnTo URL for after onboarding
|
||||
if (safeReturnTo) {
|
||||
cookies().set("returnAfterOnboarding", safeReturnTo, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
})
|
||||
}
|
||||
console.log("user has not completed onboarding, redirecting")
|
||||
redirect("/onboarding")
|
||||
// redirect("https://vemx8ac1f86.typeform.com/to/GJhrpTZH")
|
||||
} else if (safeReturnTo) {
|
||||
// User has completed onboarding, redirect to original destination
|
||||
console.log(`user has completed onboarding, redirecting to ${safeReturnTo}`)
|
||||
redirect(safeReturnTo)
|
||||
} else {
|
||||
console.log("user has completed onboarding, redirecting to /app")
|
||||
redirect("/app")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,35 @@
|
|||
"use client"
|
||||
import { getSession } from "@auth0/nextjs-auth0"
|
||||
import { redirect } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
import { hasCompletedOnboarding } from "@codeflash-ai/common"
|
||||
import { Sidebar } from "@/components/dashboard/sidebar"
|
||||
import DashboardPage from "./dashboard/page"
|
||||
|
||||
export default function TopRedirectPage(): void {
|
||||
useEffect(() => {
|
||||
/**
|
||||
* Root page that serves dashboard with proper authentication and layout
|
||||
* This ensures the sidebar shows up on the root page
|
||||
*/
|
||||
export default async function RootPage() {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session) {
|
||||
redirect("/login")
|
||||
}, [])
|
||||
}
|
||||
|
||||
const completedOnboarding = await hasCompletedOnboarding(session.user.sub)
|
||||
if (!completedOnboarding) {
|
||||
redirect("/onboarding")
|
||||
}
|
||||
|
||||
// Render dashboard with sidebar (same as dashboard layout)
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<Sidebar
|
||||
className="h-screen border-r border-border/30 flex-shrink-0 w-60 bg-background"
|
||||
user={session.user}
|
||||
/>
|
||||
<main className="flex-1 overflow-y-auto p-6">
|
||||
<DashboardPage />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,6 @@ export default async function ProSubscribePage({ searchParams }: { searchParams:
|
|||
redirect(checkoutUrl)
|
||||
} catch (error) {
|
||||
Sentry.captureException(error)
|
||||
redirect("/app/billing?error=checkout_failed")
|
||||
redirect("/billing?error=checkout_failed")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { PrismaClient } from "@prisma/client"
|
||||
import { notFound } from "next/navigation"
|
||||
import { notFound, redirect } from "next/navigation"
|
||||
import { ExperimentMetadata } from "@/lib/types" // Your defined types
|
||||
import MonacoDiffViewer from "@/components/trace/monaco-diff-viewer" // The client component
|
||||
import { Metadata } from "next" // For Next.js metadata API
|
||||
import { getSession } from "@auth0/nextjs-auth0"
|
||||
import { isTeamMember } from "@/app/utils/auth"
|
||||
|
||||
interface TraceDetailsPageProps {
|
||||
params: {
|
||||
|
|
@ -56,6 +58,58 @@ export default async function TraceDetailsPage({ params }: TraceDetailsPageProps
|
|||
notFound()
|
||||
}
|
||||
|
||||
// Check authentication - user must be logged in
|
||||
const session = await getSession()
|
||||
if (!session?.user) {
|
||||
redirect(`/login?returnTo=${encodeURIComponent(`/trace/${trace_id}`)}`)
|
||||
}
|
||||
|
||||
// Check team member access - only team members can view traces
|
||||
const hasTeamAccess = await isTeamMember()
|
||||
if (!hasTeamAccess) {
|
||||
// Create a custom access denied page or redirect to a generic error
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-200">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="mb-6">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto text-red-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-red-400 mb-4">Access Denied</h1>
|
||||
<p className="text-slate-300 mb-6">
|
||||
This trace is restricted to CodeFlash team members only.
|
||||
</p>
|
||||
<div className="text-sm text-slate-400 mb-6">
|
||||
<p>
|
||||
Logged in as:{" "}
|
||||
<span className="font-mono">{session.user.email || session.user.nickname}</span>
|
||||
</p>
|
||||
<p>
|
||||
Trace ID: <span className="font-mono">{trace_id}</span>
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="/app"
|
||||
className="inline-block bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Go to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
let optimizationFeature: {
|
||||
experiment_metadata: unknown
|
||||
metadata: unknown
|
||||
|
|
|
|||
|
|
@ -5,7 +5,64 @@ export async function getUserId(): Promise<string | null> {
|
|||
const session = await getSession()
|
||||
return session?.user?.sub || null
|
||||
}
|
||||
|
||||
export async function getUserIdAndUsername(): Promise<{ username: string; userId: string }> {
|
||||
const session = await getSession()
|
||||
return { userId: session?.user?.sub, username: session?.user?.nickname }
|
||||
|
||||
if (!session?.user?.sub || !session?.user?.nickname) {
|
||||
throw new Error("User session not found or incomplete")
|
||||
}
|
||||
|
||||
return {
|
||||
userId: session.user.sub,
|
||||
username: session.user.nickname,
|
||||
}
|
||||
}
|
||||
|
||||
// Define CodeFlash team members by email or GitHub username
|
||||
const TEAM_MEMBERS = [
|
||||
// Email addresses
|
||||
"sarthak.saga@gmail.com",
|
||||
"sarthak@codeflash.ai",
|
||||
"team@codeflash.ai",
|
||||
"saurabh@codeflash.ai",
|
||||
"hesham@codeflash.ai",
|
||||
"aseem@codeflash.ai",
|
||||
"ali@codeflash.ai",
|
||||
"turcioskevinr@gmail.com",
|
||||
// GitHub usernames (Auth0 nickname field)
|
||||
"saga4",
|
||||
"sarthaksaga",
|
||||
"codeflash-ai",
|
||||
// Add more team members here as needed
|
||||
]
|
||||
|
||||
export async function isTeamMember(): Promise<boolean> {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user) {
|
||||
return false
|
||||
}
|
||||
|
||||
const email = session.user.email?.toLowerCase()
|
||||
const nickname = session.user.nickname?.toLowerCase()
|
||||
|
||||
// Check if user's email or nickname matches team members list
|
||||
return TEAM_MEMBERS.some(member => {
|
||||
const memberLower = member.toLowerCase()
|
||||
return (email && email === memberLower) || (nickname && nickname === memberLower)
|
||||
})
|
||||
}
|
||||
|
||||
export async function requireTeamMember(): Promise<void> {
|
||||
const session = await getSession()
|
||||
|
||||
if (!session?.user) {
|
||||
throw new Error("Authentication required")
|
||||
}
|
||||
|
||||
const isTeam = await isTeamMember()
|
||||
if (!isTeam) {
|
||||
throw new Error("Access denied: Team member access required")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,42 +74,42 @@ export function Sidebar({ className, user, isLoading, error }: SidebarProps): JS
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col h-screen pt-3 pb-6 max-w-xs", className)}>
|
||||
<div className={cn("flex flex-col h-screen pt-3 pb-6 w-60 bg-background", className)}>
|
||||
<LogoBox />
|
||||
|
||||
<div className="space-y-4 py-4 grow">
|
||||
<div className="px-3 py-2">
|
||||
<div className="space-y-2 grid gap-y-1">
|
||||
<Link href="/app/home">
|
||||
<Link href="/dashboard">
|
||||
<Button
|
||||
variant={currentRoute === "/app/home" ? "secondary" : "ghost"}
|
||||
variant={currentRoute === "/dashboard" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<Home size={16} className="mr-2 h-4 w-4" />
|
||||
Home
|
||||
Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/app/repositories">
|
||||
<Link href="/repositories">
|
||||
<Button
|
||||
variant={currentRoute === "/app/repositories" ? "secondary" : "ghost"}
|
||||
variant={currentRoute === "/repositories" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<FolderGit2 size={16} className="mr-2 h-4 w-4" />
|
||||
Repositories
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/app/gettingstarted">
|
||||
<Link href="/getting-started">
|
||||
<Button
|
||||
variant={currentRoute === "/app/gettingstarted" ? "secondary" : "ghost"}
|
||||
variant={currentRoute === "/getting-started" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<CirclePlay size={16} className="mr-2 h-4 w-4" />
|
||||
Getting Started
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/app/apikeys">
|
||||
<Link href="/apikeys">
|
||||
<Button
|
||||
variant={currentRoute === "/app/apikeys" ? "secondary" : "ghost"}
|
||||
variant={currentRoute === "/apikeys" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<KeyRound size={16} className="mr-2 h-4 w-4" />
|
||||
|
|
@ -123,9 +123,9 @@ export function Sidebar({ className, user, isLoading, error }: SidebarProps): JS
|
|||
Documentation
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/app/billing">
|
||||
<Link href="/billing">
|
||||
<Button
|
||||
variant={currentRoute === "/app/billing" ? "secondary" : "ghost"}
|
||||
variant={currentRoute === "/billing" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<CreditCard size={16} className="mr-2 h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { FC } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { IntegrationConfig, IntegrationMethod } from "@/app/onboarding/types"
|
||||
import { IntegrationConfig, IntegrationMethod } from "@/app/(auth)/onboarding/types"
|
||||
|
||||
interface IntegrationCardProps {
|
||||
method: IntegrationMethod
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"
|
|||
import ProgressSteps from "@/components/onboarding/progress-steps"
|
||||
import StepNavigation from "@/components/onboarding/step-navigation"
|
||||
import LogoBox from "@/components/dashboard/logo-box"
|
||||
import { ApiKeyState, StepConfig, StepsState } from "@/app/onboarding/types"
|
||||
import { ApiKeyState, StepConfig, StepsState } from "@/app/(auth)/onboarding/types"
|
||||
import { RenderStepContent } from "./step-content-renderer"
|
||||
|
||||
interface OnboardingFlowProps {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { FC, useEffect, useRef, useState } from "react"
|
||||
import { Check } from "lucide-react"
|
||||
import { Step } from "@/app/onboarding/types"
|
||||
import { Step } from "@/app/(auth)/onboarding/types"
|
||||
|
||||
interface ProgressStepsProps {
|
||||
steps: Step[]
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { KeyRound, Copy, Check, Terminal, AlertCircle, ChevronLeft } from "lucid
|
|||
import { Button } from "@/components/ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { useState } from "react"
|
||||
import { ApiKeyState, StepConfig } from "@/app/onboarding/types"
|
||||
import { generateToken } from "@/app/app/apikeys/tokenfuncs"
|
||||
import { ApiKeyState, StepConfig } from "@/app/(auth)/onboarding/types"
|
||||
import { generateToken } from "@/app/(dashboard)/apikeys/tokenfuncs"
|
||||
|
||||
// Custom API Key Card Component
|
||||
export const ApiKeyCard = ({
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import {
|
|||
Save,
|
||||
X,
|
||||
Lock,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
} from "lucide-react"
|
||||
// Ensure you have lucide-react installed as per your package.json
|
||||
import { Loader2, FileText, AlertTriangle } from "lucide-react"
|
||||
|
|
@ -38,6 +40,8 @@ const MonacoDiffViewer: React.FC<MonacoDiffViewerProps> = ({ metadata, repoFullN
|
|||
const [currentEdit, setCurrentEdit] = useState<{ [key: string]: string }>({})
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "saving" | "saved" | "error">("idle")
|
||||
const [savedChanges, setSavedChanges] = useState<{ [key: string]: string }>({})
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
const [useInlineView, setUseInlineView] = useState(false)
|
||||
|
||||
const isEditingRef = useRef(isEditing)
|
||||
const activeFileKeyRef = useRef(activeFileKey)
|
||||
|
|
@ -206,6 +210,19 @@ const MonacoDiffViewer: React.FC<MonacoDiffViewerProps> = ({ metadata, repoFullN
|
|||
}
|
||||
}, [fileKeys, activeFileKey])
|
||||
|
||||
// Mobile detection and responsive handler
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
const mobile = window.innerWidth < 768
|
||||
setIsMobile(mobile)
|
||||
setUseInlineView(mobile)
|
||||
}
|
||||
|
||||
checkMobile()
|
||||
window.addEventListener("resize", checkMobile)
|
||||
return () => window.removeEventListener("resize", checkMobile)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (monaco) {
|
||||
// Define your custom dark theme for Monaco Editor
|
||||
|
|
@ -443,33 +460,69 @@ const MonacoDiffViewer: React.FC<MonacoDiffViewerProps> = ({ metadata, repoFullN
|
|||
|
||||
{/* File Path/Tabs */}
|
||||
{fileKeys.length === 1 ? (
|
||||
// Single file - show full path
|
||||
<div className="bg-[rgba(15,15,15,0.95)] border-b border-[rgba(255,255,255,0.05)] px-5 py-3 flex items-center gap-2">
|
||||
<FileText size={14} className="text-sky-400" />
|
||||
<span className="text-sm text-slate-300 font-mono">{fileKeys[0]}</span>
|
||||
// Single file - show full path with view toggle
|
||||
<div className="bg-[rgba(15,15,15,0.95)] border-b border-[rgba(255,255,255,0.05)] px-5 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={14} className="text-sky-400" />
|
||||
<span className="text-sm text-slate-300 font-mono">{fileKeys[0]}</span>
|
||||
</div>
|
||||
{/* View Toggle for mobile compatibility */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setUseInlineView(!useInlineView)}
|
||||
className={`flex items-center gap-2 px-3 py-1 rounded-md text-xs transition-colors ${
|
||||
useInlineView
|
||||
? "bg-blue-600/20 text-blue-400 border border-blue-600/30"
|
||||
: "bg-slate-700/50 text-slate-400 hover:text-slate-300"
|
||||
}`}
|
||||
title={useInlineView ? "Switch to side-by-side view" : "Switch to inline view"}
|
||||
>
|
||||
{useInlineView ? <Smartphone size={12} /> : <Monitor size={12} />}
|
||||
{useInlineView ? "Inline" : "Side-by-side"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Multiple files - show tabs with full path on hover
|
||||
<div className="bg-[rgba(15,15,15,0.95)] border-b border-[rgba(255,255,255,0.05)] flex overflow-x-auto whitespace-nowrap scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-slate-800">
|
||||
{fileKeys.map(fileKey => (
|
||||
<button
|
||||
key={fileKey}
|
||||
onClick={() => setActiveFileKey(fileKey)}
|
||||
title={fileKey}
|
||||
className={`px-5 py-3 text-sm transition-all duration-200 ease-in-out focus:outline-none flex items-center gap-2
|
||||
${
|
||||
activeFileKey === fileKey
|
||||
? "text-white bg-[rgba(255,255,255,0.05)] border-b-2 border-sky-400"
|
||||
: "text-slate-400 hover:text-slate-200 hover:bg-[rgba(255,255,255,0.03)] border-b-2 border-transparent"
|
||||
<div className="bg-[rgba(15,15,15,0.95)] border-b border-[rgba(255,255,255,0.05)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex overflow-x-auto whitespace-nowrap scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-slate-800">
|
||||
{fileKeys.map(fileKey => (
|
||||
<button
|
||||
key={fileKey}
|
||||
onClick={() => setActiveFileKey(fileKey)}
|
||||
title={fileKey}
|
||||
className={`px-5 py-3 text-sm transition-all duration-200 ease-in-out focus:outline-none flex items-center gap-2
|
||||
${
|
||||
activeFileKey === fileKey
|
||||
? "text-white bg-[rgba(255,255,255,0.05)] border-b-2 border-sky-400"
|
||||
: "text-slate-400 hover:text-slate-200 hover:bg-[rgba(255,255,255,0.03)] border-b-2 border-transparent"
|
||||
}`}
|
||||
>
|
||||
<FileText
|
||||
size={14}
|
||||
className={activeFileKey === fileKey ? "text-sky-400" : "text-slate-500"}
|
||||
/>
|
||||
{fileKey.split("/").pop()} {/* Show only filename */}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* View Toggle for mobile compatibility */}
|
||||
<div className="flex items-center gap-2 px-5">
|
||||
<button
|
||||
onClick={() => setUseInlineView(!useInlineView)}
|
||||
className={`flex items-center gap-2 px-3 py-1 rounded-md text-xs transition-colors ${
|
||||
useInlineView
|
||||
? "bg-blue-600/20 text-blue-400 border border-blue-600/30"
|
||||
: "bg-slate-700/50 text-slate-400 hover:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
<FileText
|
||||
size={14}
|
||||
className={activeFileKey === fileKey ? "text-sky-400" : "text-slate-500"}
|
||||
/>
|
||||
{fileKey.split("/").pop()} {/* Show only filename */}
|
||||
</button>
|
||||
))}
|
||||
title={useInlineView ? "Switch to side-by-side view" : "Switch to inline view"}
|
||||
>
|
||||
{useInlineView ? <Smartphone size={12} /> : <Monitor size={12} />}
|
||||
{useInlineView ? "Inline" : "Side-by-side"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -487,27 +540,34 @@ const MonacoDiffViewer: React.FC<MonacoDiffViewerProps> = ({ metadata, repoFullN
|
|||
onMount={handleEditorOnMount}
|
||||
options={{
|
||||
automaticLayout: true,
|
||||
renderSideBySide: true,
|
||||
renderSideBySide: !useInlineView,
|
||||
readOnly: false, // Allow editing on the modified side
|
||||
scrollBeyondLastLine: false,
|
||||
minimap: { enabled: true, scale: 1, size: "proportional" },
|
||||
minimap: { enabled: !isMobile, scale: isMobile ? 0.5 : 1, size: "proportional" },
|
||||
diffCodeLens: true,
|
||||
renderIndicators: true,
|
||||
ignoreTrimWhitespace: false,
|
||||
renderWhitespace: "boundary",
|
||||
fontSize: 13,
|
||||
fontSize: isMobile ? 12 : 13,
|
||||
fontFamily: "'Fira Code', 'Cascadia Code', Consolas, 'Courier New', monospace",
|
||||
fontLigatures: true,
|
||||
wordWrap: "off",
|
||||
fontLigatures: !isMobile, // Disable font ligatures on mobile for better performance
|
||||
wordWrap: isMobile ? "on" : "off", // Enable word wrap on mobile
|
||||
scrollbar: {
|
||||
verticalScrollbarSize: 10,
|
||||
horizontalScrollbarSize: 10,
|
||||
verticalScrollbarSize: isMobile ? 14 : 10,
|
||||
horizontalScrollbarSize: isMobile ? 14 : 10,
|
||||
useShadows: false,
|
||||
},
|
||||
originalEditable: false, // Original side stays read-only
|
||||
enableSplitViewResizing: true,
|
||||
enableSplitViewResizing: !isMobile,
|
||||
// Enable diff review mode for better editing experience
|
||||
diffWordWrap: "off",
|
||||
diffWordWrap: isMobile ? "on" : "off",
|
||||
// Mobile-specific optimizations
|
||||
mouseWheelZoom: !isMobile,
|
||||
contextmenu: !isMobile,
|
||||
quickSuggestions: !isMobile,
|
||||
parameterHints: { enabled: !isMobile },
|
||||
suggest: !isMobile ? {} : undefined,
|
||||
hover: { enabled: !isMobile },
|
||||
}}
|
||||
loading={
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0A0A0A] text-slate-400">
|
||||
|
|
@ -527,25 +587,32 @@ const MonacoDiffViewer: React.FC<MonacoDiffViewerProps> = ({ metadata, repoFullN
|
|||
onMount={handleEditorOnMount}
|
||||
options={{
|
||||
automaticLayout: true,
|
||||
renderSideBySide: true,
|
||||
renderSideBySide: !useInlineView,
|
||||
readOnly: true,
|
||||
scrollBeyondLastLine: false,
|
||||
minimap: { enabled: true, scale: 1, size: "proportional" },
|
||||
minimap: { enabled: !isMobile, scale: isMobile ? 0.5 : 1, size: "proportional" },
|
||||
diffCodeLens: true,
|
||||
renderIndicators: true,
|
||||
ignoreTrimWhitespace: false,
|
||||
renderWhitespace: "boundary",
|
||||
fontSize: 13,
|
||||
fontSize: isMobile ? 12 : 13,
|
||||
fontFamily: "'Fira Code', 'Cascadia Code', Consolas, 'Courier New', monospace",
|
||||
fontLigatures: true,
|
||||
wordWrap: "off",
|
||||
fontLigatures: !isMobile, // Disable font ligatures on mobile for better performance
|
||||
wordWrap: isMobile ? "on" : "off", // Enable word wrap on mobile
|
||||
scrollbar: {
|
||||
verticalScrollbarSize: 10,
|
||||
horizontalScrollbarSize: 10,
|
||||
verticalScrollbarSize: isMobile ? 14 : 10,
|
||||
horizontalScrollbarSize: isMobile ? 14 : 10,
|
||||
useShadows: false,
|
||||
},
|
||||
originalEditable: false,
|
||||
enableSplitViewResizing: true,
|
||||
enableSplitViewResizing: !isMobile,
|
||||
// Mobile-specific optimizations
|
||||
mouseWheelZoom: !isMobile,
|
||||
contextmenu: !isMobile,
|
||||
quickSuggestions: false,
|
||||
parameterHints: { enabled: false },
|
||||
suggest: undefined,
|
||||
hover: { enabled: !isMobile },
|
||||
}}
|
||||
loading={
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-[#0A0A0A] text-slate-400">
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { FC } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { CopyButton } from "@/app/app/gettingstarted/copy-button"
|
||||
import { CopyButton } from "@/app/(dashboard)/getting-started/copy-button"
|
||||
|
||||
interface TerminalCardProps {
|
||||
command: string
|
||||
|
|
|
|||
|
|
@ -1,13 +1,6 @@
|
|||
import * as Sentry from "@sentry/nextjs"
|
||||
|
||||
export function register() {
|
||||
Sentry.init({
|
||||
dsn: "https://0fa0f40b2d709e4f1eb9aac76ff9e6be@o4506833230561280.ingest.us.sentry.io/4506833279582208",
|
||||
|
||||
// Adjust this value in production, or use tracesSampler for greater control
|
||||
tracesSampleRate: 1,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: false,
|
||||
})
|
||||
// Sentry initialization is now handled by dedicated config files:
|
||||
// - sentry.server.config.ts for server-side
|
||||
// - sentry.client.config.ts for client-side
|
||||
// This prevents duplicate initialization issues with Sentry v9
|
||||
}
|
||||
|
|
|
|||
46
js/cf-webapp/src/lib/analytics/tracking.ts
Normal file
46
js/cf-webapp/src/lib/analytics/tracking.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import PostHogClient from "../posthog"
|
||||
|
||||
function getPostHogClient() {
|
||||
return PostHogClient()
|
||||
}
|
||||
|
||||
// Main tracking function for user login
|
||||
export async function trackUserLogin(userData: {
|
||||
userId: string
|
||||
username: string
|
||||
email?: string
|
||||
name?: string
|
||||
}) {
|
||||
try {
|
||||
const posthog = getPostHogClient()
|
||||
|
||||
// Identify the user
|
||||
posthog.identify({
|
||||
distinctId: userData.userId,
|
||||
properties: {
|
||||
username: userData.username,
|
||||
email: userData.email,
|
||||
name: userData.name,
|
||||
},
|
||||
})
|
||||
|
||||
// Track the login event
|
||||
posthog.capture({
|
||||
distinctId: userData.userId,
|
||||
event: "webapp-user-logged-in",
|
||||
properties: {
|
||||
username: userData.username,
|
||||
email: userData.email,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
// Ensure events are sent
|
||||
await posthog.shutdown()
|
||||
|
||||
console.log(`[Analytics] Tracked login for user ${userData.userId}`)
|
||||
} catch (error) {
|
||||
// Don't let tracking errors break login
|
||||
console.error("[Analytics] Failed to track login:", error)
|
||||
}
|
||||
}
|
||||
22
js/cf-webapp/src/lib/api-client.ts
Normal file
22
js/cf-webapp/src/lib/api-client.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export async function fetchFromAPI(endpoint: string, options: RequestInit = {}) {
|
||||
const apiKey = process.env.NEXT_PUBLIC_CF_API_KEY || localStorage.getItem("cf_api_key")
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001"}${endpoint}`,
|
||||
{
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey || "",
|
||||
...options.headers,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.message || "API request failed")
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
114
js/cf-webapp/src/lib/modified-code-utils.ts
Normal file
114
js/cf-webapp/src/lib/modified-code-utils.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { PrismaClient } from "@prisma/client"
|
||||
import { ExperimentMetadata } from "./types"
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
/**
|
||||
* Get the modified code for a trace, falling back to original optimized code if no modifications exist
|
||||
* @param traceId - The trace ID to get code for
|
||||
* @param fileKey - Optional specific file key, if not provided returns all files
|
||||
* @returns Modified code or original code if no modifications exist
|
||||
*/
|
||||
export async function getModifiedCodeForTrace(
|
||||
traceId: string,
|
||||
fileKey?: string,
|
||||
): Promise<{ [fileKey: string]: string } | string | null> {
|
||||
try {
|
||||
const optimizationFeature = await prisma.optimization_features.findUnique({
|
||||
where: { trace_id: traceId },
|
||||
select: {
|
||||
experiment_metadata: true,
|
||||
metadata: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!optimizationFeature) {
|
||||
return null
|
||||
}
|
||||
|
||||
const experimentMetadata = optimizationFeature.experiment_metadata as ExperimentMetadata | null
|
||||
const additionalMetadata = (optimizationFeature.metadata as any) || {}
|
||||
|
||||
// Get modified code from metadata if available
|
||||
const modifiedCode = additionalMetadata.modifiedCode as { [key: string]: string } | undefined
|
||||
|
||||
if (fileKey) {
|
||||
// Return specific file
|
||||
if (modifiedCode && modifiedCode[fileKey]) {
|
||||
return modifiedCode[fileKey]
|
||||
}
|
||||
// Fall back to original optimized code
|
||||
if (experimentMetadata?.diffContents?.[fileKey]) {
|
||||
return (
|
||||
experimentMetadata.diffContents[fileKey].newContent ||
|
||||
experimentMetadata.diffContents[fileKey].oldContent ||
|
||||
""
|
||||
)
|
||||
}
|
||||
return null
|
||||
} else {
|
||||
// Return all files
|
||||
const result: { [fileKey: string]: string } = {}
|
||||
|
||||
// Start with original optimized code
|
||||
if (experimentMetadata?.diffContents) {
|
||||
Object.entries(experimentMetadata.diffContents).forEach(([key, diff]) => {
|
||||
result[key] = diff.newContent || diff.oldContent || ""
|
||||
})
|
||||
}
|
||||
|
||||
// Override with modified code where available
|
||||
if (modifiedCode) {
|
||||
Object.entries(modifiedCode).forEach(([key, code]) => {
|
||||
result[key] = code
|
||||
})
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error getting modified code for trace:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a trace has any modified code
|
||||
* @param traceId - The trace ID to check
|
||||
* @returns True if trace has modified code, false otherwise
|
||||
*/
|
||||
export async function hasModifiedCode(traceId: string): Promise<boolean> {
|
||||
try {
|
||||
const optimizationFeature = await prisma.optimization_features.findUnique({
|
||||
where: { trace_id: traceId },
|
||||
select: {
|
||||
metadata: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!optimizationFeature) {
|
||||
return false
|
||||
}
|
||||
|
||||
const additionalMetadata = (optimizationFeature.metadata as any) || {}
|
||||
const modifiedCode = additionalMetadata.modifiedCode as { [key: string]: string } | undefined
|
||||
|
||||
return !!(modifiedCode && Object.keys(modifiedCode).length > 0)
|
||||
} catch (error) {
|
||||
console.error("Error checking for modified code:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the most recent code for suggestions - modified code takes precedence over original
|
||||
* This is the main function other services should use when they need code for suggestions
|
||||
* @param traceId - The trace ID
|
||||
* @returns Object with file paths as keys and code content as values
|
||||
*/
|
||||
export async function getCodeForSuggestions(
|
||||
traceId: string,
|
||||
): Promise<{ [fileKey: string]: string } | null> {
|
||||
const code = await getModifiedCodeForTrace(traceId)
|
||||
return typeof code === "object" ? code : null
|
||||
}
|
||||
|
|
@ -7,4 +7,3 @@ export default function PostHogClient(): PostHog {
|
|||
flushInterval: 0,
|
||||
})
|
||||
}
|
||||
// "phc_aUO790jHd7z1SXwsYCz8dRApxueplZlZWeDSpKc5hol", { api_host: "https://app.posthog.com" }
|
||||
9
js/cf-webapp/src/lib/stripe.ts
Normal file
9
js/cf-webapp/src/lib/stripe.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import Stripe from "stripe"
|
||||
|
||||
if (!process.env.STRIPE_SECRET_KEY) {
|
||||
throw new Error("STRIPE_SECRET_KEY is not set in environment variables")
|
||||
}
|
||||
|
||||
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||
apiVersion: "2025-05-28.basil",
|
||||
})
|
||||
|
|
@ -6,5 +6,5 @@ export default withMiddlewareAuthRequired({
|
|||
})
|
||||
|
||||
export const config = {
|
||||
matcher: "/app/:path*",
|
||||
matcher: ["/app/:path*", "/trace/:path*"],
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue