From 64d34aa7bb2dd8e8ca4ff31b09dbe2e422c13fe1 Mon Sep 17 00:00:00 2001 From: Brace Sproul Date: Mon, 23 Jun 2025 17:52:55 -0700 Subject: [PATCH] feat: Force user auth (#308) * feat: Force user auth * cr * cr --- apps/web/src/app/page.tsx | 17 +- .../web/src/components/github/auth-status.tsx | 375 ++++------ .../components/github/github-oauth-button.tsx | 70 +- apps/web/src/components/thread/index.tsx | 670 ------------------ apps/web/src/components/v2/default-view.tsx | 2 + 5 files changed, 171 insertions(+), 963 deletions(-) delete mode 100644 apps/web/src/components/thread/index.tsx diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 860ac08e..c821e1c9 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,23 +1,16 @@ "use client"; -import { Thread } from "@/components/thread"; -import { ThreadProvider } from "@/providers/Thread"; -import { StreamProvider } from "@/providers/Stream"; import { Toaster } from "@/components/ui/sonner"; import React from "react"; -import { GitHubAppProvider } from "@/providers/GitHubApp"; +import AuthStatus from "@/components/github/auth-status"; -export default function DemoPage(): React.ReactNode { +export default function Page(): React.ReactNode { return ( Loading (layout)...}> - - - - - - - +
+ +
); } diff --git a/apps/web/src/components/github/auth-status.tsx b/apps/web/src/components/github/auth-status.tsx index bb0e263c..9fa51e44 100644 --- a/apps/web/src/components/github/auth-status.tsx +++ b/apps/web/src/components/github/auth-status.tsx @@ -2,35 +2,29 @@ import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { GitHubSVG } from "@/components/icons/github"; -import { LogOut } from "lucide-react"; -import { Badge } from "@/components/ui/badge"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; - -const GITHUB_APP_INSTALL_URL = "/api/github/installation"; -const GITHUB_LOGIN_URL = "/api/auth/github/login"; -const GITHUB_LOGOUT_URL = "/api/auth/logout"; - -interface GitHubUser { - login: string; - avatar_url: string; - html_url: string; - name?: string; - email?: string; -} +import { ArrowRight } from "lucide-react"; +import { LangGraphLogoSVG } from "../icons/langgraph"; +import { useGitHubToken } from "@/hooks/useGitHubToken"; export default function AuthStatus() { const [isAuth, setIsAuth] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [isInstalled, setIsInstalled] = useState(null); - const [isInstallLoading, setIsInstallLoading] = useState(false); - const [error, setError] = useState(null); - const [user, setUser] = useState(null); - const [userLoading, setUserLoading] = useState(false); - const [popoverOpen, setPopoverOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const [hasGitHubAppInstalled, setHasGitHubAppInstalled] = useState< + boolean | null + >(() => { + if (typeof window === "undefined") return null; + const cached = localStorage.getItem("github_app_installed"); + return cached === "true" ? true : cached === "false" ? false : null; + }); + + const [isCheckingAppInstallation, setIsCheckingAppInstallation] = + useState(false); + const { + token: githubToken, + fetchToken: fetchGitHubToken, + isLoading: isTokenLoading, + } = useGitHubToken(); useEffect(() => { checkAuthStatus(); @@ -38,225 +32,170 @@ export default function AuthStatus() { useEffect(() => { if (isAuth) { - checkAppInstallation(); - fetchGitHubUser(); - } else { - setIsInstalled(null); - setUser(null); + const cachedInstallationStatus = localStorage.getItem( + "github_app_installed", + ); + if (cachedInstallationStatus === "true") { + setHasGitHubAppInstalled(true); + // Fetch token if we don't have one yet + if (!githubToken && !isTokenLoading) { + fetchGitHubToken(); + } + } else if (cachedInstallationStatus === "false") { + setHasGitHubAppInstalled(false); + } else { + checkGitHubAppInstallation(); + } } - }, [isAuth]); + }, [isAuth, githubToken]); const checkAuthStatus = async () => { - setIsLoading(true); try { const response = await fetch("/api/auth/status"); const data = await response.json(); setIsAuth(data.authenticated); - } catch (err) { + } catch (error) { + console.error("Error checking auth status:", error); setIsAuth(false); - } finally { - setIsLoading(false); } }; - const checkAppInstallation = async () => { - setIsInstallLoading(true); + const checkGitHubAppInstallation = async () => { + setIsCheckingAppInstallation(true); try { const response = await fetch("/api/github/repositories"); if (response.ok) { - setIsInstalled(true); + setHasGitHubAppInstalled(true); + localStorage.setItem("github_app_installed", "true"); + + await fetchGitHubToken(); } else { - const data = await response.json(); - if (data.error && data.error.includes("installation")) { - setIsInstalled(false); + const errorData = await response.json(); + if (errorData.error.includes("installation")) { + setHasGitHubAppInstalled(false); + localStorage.setItem("github_app_installed", "false"); } else { - setError(data.error || "Unknown error"); + setHasGitHubAppInstalled(false); + localStorage.setItem("github_app_installed", "false"); } } - } catch (err) { - setError("Failed to check GitHub App installation status"); + } catch (error) { + console.error("Error checking GitHub App installation:", error); + setHasGitHubAppInstalled(false); + localStorage.setItem("github_app_installed", "false"); } finally { - setIsInstallLoading(false); - } - }; - - const fetchGitHubUser = async () => { - setUserLoading(true); - try { - const response = await fetch("/api/auth/user"); - if (response.ok) { - const data = await response.json(); - setUser(data.user); - } else { - setUser(null); - } - } catch { - setUser(null); - } finally { - setUserLoading(false); + setIsCheckingAppInstallation(false); } }; const handleLogin = () => { - window.location.href = GITHUB_LOGIN_URL; - }; - - const handleLogout = async () => { setIsLoading(true); - try { - await fetch(GITHUB_LOGOUT_URL, { method: "POST" }); - window.location.href = "/"; - } catch { - setIsLoading(false); - } + window.location.href = "/api/auth/github/login"; }; - const handleInstall = () => { - window.location.href = GITHUB_APP_INSTALL_URL; + const handleInstallGitHubApp = () => { + setIsLoading(true); + + localStorage.removeItem("github_app_installed"); + window.location.href = "/api/github/installation"; }; - return ( -
- {isLoading ? ( - - ) : isAuth ? ( - - - - - -
- {userLoading ? ( -
- -
-
-
-
-
- ) : user ? ( -
- {user.login} -
-
- {user.name || user.login} -
-
- {user.email || user.login} -
-
-
- ) : null} - {isInstallLoading ? ( - - ) : isInstalled === false ? ( - - ) : isInstalled === true ? ( - - {" "} - App Installed - - ) : null} - + if (!isAuth) { + return ( +
+
+
+
+ +

+ Get started +

- - - ) : ( - - )} - {error && {error}} -
- ); +

+ Connect your GitHub account to get started with Open SWE. +

+ +
+
+
+ ); + } + + if (isAuth && hasGitHubAppInstalled === false && !isTokenLoading) { + return ( +
+
+
+
+ +

+ One more step +

+
+
+ + 1. GitHub Login ✓ + + + + 2. Repository Access + +
+

+ Great! Now we need access to your GitHub repositories. Install our + GitHub App to grant access to specific repositories. +

+
+

+ You'll be redirected to GitHub where you can select which + repositories to grant access to. +

+
+ +
+
+
+ ); + } + + if (!githubToken) { + return ( +
+
+
+
+ +

+ Loading... +

+
+

+ Setting up your GitHub integration... +

+
+
+
+ ); + } } diff --git a/apps/web/src/components/github/github-oauth-button.tsx b/apps/web/src/components/github/github-oauth-button.tsx index dfe633b7..ec9a545e 100644 --- a/apps/web/src/components/github/github-oauth-button.tsx +++ b/apps/web/src/components/github/github-oauth-button.tsx @@ -1,35 +1,12 @@ "use client"; -import { useState, useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { GitHubSVG } from "@/components/icons/github"; +import { useState } from "react"; import { LogOut } from "lucide-react"; import { TooltipIconButton } from "../ui/tooltip-icon-button"; -export function GitHubOAuthButton() { - const [isAuth, setIsAuth] = useState(null); +export function GitHubLogoutButton() { const [isLoading, setIsLoading] = useState(false); - useEffect(() => { - checkAuthStatus(); - }, []); - - const checkAuthStatus = async () => { - try { - const response = await fetch("/api/auth/status"); - const data = await response.json(); - setIsAuth(data.authenticated); - } catch (error) { - console.error("Error checking auth status:", error); - setIsAuth(false); - } - }; - - const handleLogin = () => { - setIsLoading(true); - window.location.href = "/api/auth/github/login"; - }; - const handleLogout = async () => { setIsLoading(true); try { @@ -38,7 +15,6 @@ export function GitHubOAuthButton() { }); if (response.ok) { localStorage.removeItem("github_app_installed"); - setIsAuth(false); window.location.href = "/"; } else { console.error("Logout failed"); @@ -50,47 +26,15 @@ export function GitHubOAuthButton() { } }; - if (isAuth === null) { - return ( - - ); - } - - if (isAuth) { - return ( - - - - ); - } - return ( - + + ); } diff --git a/apps/web/src/components/thread/index.tsx b/apps/web/src/components/thread/index.tsx deleted file mode 100644 index 3b02a2f0..00000000 --- a/apps/web/src/components/thread/index.tsx +++ /dev/null @@ -1,670 +0,0 @@ -import { v4 as uuidv4 } from "uuid"; -import { ReactNode, useEffect, useRef } from "react"; -import { motion } from "framer-motion"; -import { cn } from "@/lib/utils"; -import { useStreamContext } from "@/providers/Stream"; -import { useState, FormEvent } from "react"; -import { Button } from "../ui/button"; -import { Checkpoint, Message } from "@langchain/langgraph-sdk"; -import { AssistantMessage, AssistantMessageLoading } from "./messages/ai"; -import { HumanMessage } from "./messages/human"; -import { ensureToolCallsHaveResponses } from "@/lib/ensure-tool-responses"; -import { LangGraphLogoSVG } from "../icons/langgraph"; -import { TooltipIconButton } from "../ui/tooltip-icon-button"; -import { - ArrowDown, - LoaderCircle, - PanelRightOpen, - PanelRightClose, - SquarePen, - Settings, - FilePlus2, -} from "lucide-react"; -import { ThemeToggle } from "../theme-toggle"; -import { useQueryState, parseAsBoolean, parseAsString } from "nuqs"; -import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; -import TaskListSidebar from "../task-list-sidebar"; -import { toast } from "sonner"; -import { useMediaQuery } from "@/hooks/useMediaQuery"; -import { Label } from "../ui/label"; -import { useFileUpload } from "@/hooks/useFileUpload"; -import { ContentBlocksPreview } from "./ContentBlocksPreview"; -import { useGitHubAppProvider } from "@/providers/GitHubApp"; -import TaskList from "../task-list"; -import { ConfigurationSidebar } from "../configuration-sidebar"; -import { DEFAULT_CONFIG_KEY, useConfigStore } from "@/hooks/useConfigStore"; -import { RepositoryBranchSelectors } from "../github/repo-branch-selectors"; -import { useRouter } from "next/navigation"; -import { OpenPRButton } from "../github/open-pr-button"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "../ui/tooltip"; -import { BaseMessage } from "@langchain/core/messages"; -import { TaskPlanView } from "../tasks"; -import { useTaskPlan } from "../tasks/useTaskPlan"; -import { isProposedPlanInterrupt } from "@/lib/plan-utils"; -import { HumanResponse } from "@langchain/langgraph/prebuilt"; -import { - INITIALIZE_NODE_ID, - mapCustomEventsToSteps, -} from "@open-swe/shared/open-swe/custom-node-events"; -import { DO_NOT_RENDER_ID_PREFIX } from "@open-swe/shared/constants"; -import AuthStatus from "../github/auth-status"; - -function StickyToBottomContent(props: { - content: ReactNode; - footer?: ReactNode; - className?: string; - contentClassName?: string; -}) { - const context = useStickToBottomContext(); - return ( -
-
- {props.content} -
- - {props.footer} -
- ); -} - -function ScrollToBottom(props: { className?: string }) { - const { isAtBottom, scrollToBottom } = useStickToBottomContext(); - - if (isAtBottom) return null; - return ( - - ); -} - -export function Thread() { - const { push } = useRouter(); - const { selectedRepository } = useGitHubAppProvider(); - const { getConfig } = useConfigStore(); - const { taskPlan } = useTaskPlan(); - - const [threadId, _setThreadId] = useQueryState("threadId"); - const [taskId, setTaskId] = useQueryState("taskId", parseAsString); - const [chatHistoryOpen, setChatHistoryOpen] = useQueryState( - "chatHistoryOpen", - parseAsBoolean.withDefault(false), - ); - const [configSidebarOpen, setConfigSidebarOpen] = useState(false); - - const isTaskView = !!taskId; - const isThreadView = !!threadId; - - // Track previous states to detect navigation changes - const prevTaskId = useRef(taskId); - const prevThreadId = useRef(threadId); - - useEffect(() => { - const isNavigatingToTask = !prevTaskId.current && taskId; - const isNavigatingToThread = !prevThreadId.current && threadId; - if ((isNavigatingToTask || isNavigatingToThread) && !chatHistoryOpen) { - setChatHistoryOpen(true); - } - prevTaskId.current = taskId; - prevThreadId.current = threadId; - }, [taskId, threadId, chatHistoryOpen, setChatHistoryOpen]); - - useEffect(() => { - if (taskId && typeof window !== "undefined") { - // TaskId format is "${threadId}-${taskIndex}", so we can extract the threadId directly - const taskThreadId = taskId.split("-").slice(0, -1).join("-"); - if (taskThreadId && taskThreadId !== threadId) { - _setThreadId(taskThreadId); - } - } - }, [taskId, threadId, _setThreadId]); - - const [input, setInput] = useState(""); - const { - contentBlocks, - setContentBlocks, - handleFileUpload, - dropRef, - removeBlock, - dragOver, - handlePaste, - } = useFileUpload(); - const [firstTokenReceived, setFirstTokenReceived] = useState(false); - const isLargeScreen = useMediaQuery("(min-width: 1024px)"); - - const stream = useStreamContext(); - const messages = stream.messages; - const isLoading = stream.isLoading; - const customEvents = stream.customEvents; - - const lastError = useRef(undefined); - - const setThreadId = (id: string | null) => { - _setThreadId(id); - - if (id === null) { - setTaskId(null); - } - }; - - useEffect(() => { - if (!stream.error) { - lastError.current = undefined; - return; - } - try { - const message = (stream.error as any).message; - if (!message || lastError.current === message) { - return; - } - - lastError.current = message; - toast.error("An error occurred. Please try again.", { - description: ( -

- Error: {message} -

- ), - richColors: true, - closeButton: true, - }); - } catch { - console.error("Error in stream", stream.error); - } - }, [stream.error]); - - const prevMessageLength = useRef(0); - useEffect(() => { - if ( - messages.length !== prevMessageLength.current && - messages?.length && - messages[messages.length - 1].type === "ai" - ) { - setFirstTokenReceived(true); - } - - prevMessageLength.current = messages.length; - }, [messages]); - - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - if ((input.trim().length === 0 && contentBlocks.length === 0) || isLoading) - return; - - if (!selectedRepository) { - toast.error("Please select a repository first", { - description: - "You need to select a repository before sending a message.", - richColors: true, - closeButton: true, - }); - return; - } - - setFirstTokenReceived(false); - - if (isProposedPlanInterrupt(stream.interrupt)) { - const resume: HumanResponse[] = [ - { - type: "response", - args: input.trim(), - }, - ]; - stream.submit( - {}, - { - command: { - resume, - }, - }, - ); - if (contentBlocks.length > 0) { - toast.warning( - "Content blocks were not submitted with the plan response.", - { - richColors: true, - closeButton: true, - }, - ); - } - setInput(""); - setContentBlocks([]); - return; - } - - const newHumanMessage: Message = { - id: uuidv4(), - type: "human", - content: [ - ...(input.trim().length > 0 ? [{ type: "text", text: input }] : []), - ...contentBlocks, - ] as Message["content"], - }; - - const toolMessages = ensureToolCallsHaveResponses(stream.messages); - - const newMessages = [ - ...toolMessages, - newHumanMessage, - ] as unknown as BaseMessage[]; - stream.submit( - { - messages: newMessages, - internalMessages: newMessages, - targetRepository: selectedRepository, - }, - { - streamMode: ["values"], - optimisticValues: (prev) => ({ - ...prev, - messages: [ - ...(prev.messages ?? []), - ...toolMessages, - newHumanMessage, - ] as unknown as BaseMessage[], - }), - config: { - recursion_limit: 400, - configurable: { - ...getConfig(threadId || DEFAULT_CONFIG_KEY), - }, - }, - metadata: { - graph_id: process.env.NEXT_PUBLIC_ASSISTANT_ID ?? "open-swe", - }, - }, - ); - - setInput(""); - setContentBlocks([]); - }; - - const handleRegenerate = ( - parentCheckpoint: Checkpoint | null | undefined, - ) => { - // Do this so the loading state is correct - prevMessageLength.current = prevMessageLength.current - 1; - setFirstTokenReceived(false); - stream.submit(undefined, { - checkpoint: parentCheckpoint, - streamMode: ["values"], - config: { - recursion_limit: 400, - configurable: { - ...getConfig(threadId || DEFAULT_CONFIG_KEY), - }, - }, - metadata: { - graph_id: process.env.NEXT_PUBLIC_ASSISTANT_ID ?? "open-swe", - }, - }); - }; - - const chatStarted = !!threadId || !!messages.length; - const hasNoAIOrToolMessages = !messages.find( - (m) => m.type === "ai" || m.type === "tool", - ); - const isLastMessageHuman = messages[messages.length - 1]?.type === "human"; - - const initializeEvents = customEvents.filter( - (e) => e.nodeId === INITIALIZE_NODE_ID, - ); - - const steps = mapCustomEventsToSteps(initializeEvents); - const allSuccess = - steps.length > 0 && steps.every((s) => s.status === "success"); - - let initStatus: "loading" | "generating" | "done" = "generating"; - if (allSuccess) { - initStatus = "done"; - } - - return ( -
-
- -
- setChatHistoryOpen(false)} /> -
-
-
- -
- - {!chatStarted && ( -
-
- {(!chatHistoryOpen || !isLargeScreen) && ( - - )} -
-
- - { - setConfigSidebarOpen(true); - }} - > - - - -
-
- )} - {chatStarted && ( -
-
-
- {(!chatHistoryOpen || !isLargeScreen) && ( - - )} -
- push("/")} - animate={{ - marginLeft: !chatHistoryOpen ? 48 : 0, - }} - transition={{ - type: "spring", - stiffness: 300, - damping: 30, - }} - > - - - Open SWE - - -
- -
- {taskPlan && ( - {}} - /> - )} -
- -
- - - { - setConfigSidebarOpen(true); - }} - > - - -
- -
-
- )} - - - - {messages - .filter((m) => !m.id?.startsWith(DO_NOT_RENDER_ID_PREFIX)) - .map((message, index) => - message.type === "human" ? ( - - ) : ( - - ), - )} - {/* Special rendering case where there are no AI/tool messages, but there is an interrupt. - We need to render it outside of the messages list, since there are no messages to render */} - {(hasNoAIOrToolMessages || isLastMessageHuman) && - !!stream.interrupt && ( - - )} - {isLoading && - !firstTokenReceived && - initializeEvents.length === 0 && ( - - )} - - } - footer={ -
- {!chatStarted && ( -
- -

- Open SWE -

-
- )} - - - -
-
- -