From e7c5aa909302407cd12f43597ad52d8cdc84d91a Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 23 Oct 2025 21:54:19 -0700 Subject: [PATCH 1/2] WIP --- .../src/app/callback/github/install/page.tsx | 9 +- apps/web/client/src/app/login/actions.tsx | 2 + .../import/_components/background-wrapper.tsx | 20 + .../src/app/projects/import/cancel-button.tsx | 14 - .../import/github/_components/connect.tsx | 116 ----- .../import/github/_components/finalizing.tsx | 48 -- .../import/github/_components/import-flow.tsx | 16 + .../github/_components/oauth-connect.tsx | 116 +++++ .../import/github/_components/setup.tsx | 79 +-- .../projects/import/github/_context/index.tsx | 184 ------- .../projects/import/github/_hooks/index.ts | 5 - .../projects/import/github/_hooks/use-data.ts | 75 --- .../import/github/_hooks/use-installation.ts | 56 -- .../import/github/_hooks/use-repo-import.ts | 72 --- .../github/_hooks/use-repo-validation.ts | 40 -- .../src/app/projects/import/github/actions.ts | 39 ++ .../projects/import/github/importing/page.tsx | 122 +++++ .../src/app/projects/import/github/layout.tsx | 23 - .../app/projects/import/github/loading.tsx | 32 ++ .../src/app/projects/import/github/page.tsx | 88 +--- .../client/src/app/projects/import/layout.tsx | 13 +- .../_components/import-local-project.tsx | 63 --- .../local/_components/select-folder.tsx | 5 +- .../src/app/projects/import/local/page.tsx | 78 +-- .../client/src/app/projects/import/page.tsx | 20 +- .../client/src/server/api/routers/github.ts | 225 +++++--- docs/github-app-setup-testing.md | 171 +++++++ docs/github-import-production-plan.md | 315 ++++++++++++ docs/github-oauth-setup.md | 482 ++++++++++++++++++ packages/github/README.md | 147 +++++- packages/github/src/index.ts | 2 +- packages/github/src/types.ts | 22 - 32 files changed, 1732 insertions(+), 967 deletions(-) create mode 100644 apps/web/client/src/app/projects/import/_components/background-wrapper.tsx delete mode 100644 apps/web/client/src/app/projects/import/cancel-button.tsx delete mode 100644 apps/web/client/src/app/projects/import/github/_components/connect.tsx delete mode 100644 apps/web/client/src/app/projects/import/github/_components/finalizing.tsx create mode 100644 apps/web/client/src/app/projects/import/github/_components/import-flow.tsx create mode 100644 apps/web/client/src/app/projects/import/github/_components/oauth-connect.tsx delete mode 100644 apps/web/client/src/app/projects/import/github/_context/index.tsx delete mode 100644 apps/web/client/src/app/projects/import/github/_hooks/index.ts delete mode 100644 apps/web/client/src/app/projects/import/github/_hooks/use-data.ts delete mode 100644 apps/web/client/src/app/projects/import/github/_hooks/use-installation.ts delete mode 100644 apps/web/client/src/app/projects/import/github/_hooks/use-repo-import.ts delete mode 100644 apps/web/client/src/app/projects/import/github/_hooks/use-repo-validation.ts create mode 100644 apps/web/client/src/app/projects/import/github/actions.ts create mode 100644 apps/web/client/src/app/projects/import/github/importing/page.tsx delete mode 100644 apps/web/client/src/app/projects/import/github/layout.tsx create mode 100644 apps/web/client/src/app/projects/import/github/loading.tsx delete mode 100644 apps/web/client/src/app/projects/import/local/_components/import-local-project.tsx create mode 100644 docs/github-app-setup-testing.md create mode 100644 docs/github-import-production-plan.md create mode 100644 docs/github-oauth-setup.md delete mode 100644 packages/github/src/types.ts diff --git a/apps/web/client/src/app/callback/github/install/page.tsx b/apps/web/client/src/app/callback/github/install/page.tsx index 919f34dfc2..0f09d55c77 100644 --- a/apps/web/client/src/app/callback/github/install/page.tsx +++ b/apps/web/client/src/app/callback/github/install/page.tsx @@ -26,9 +26,16 @@ export default function GitHubInstallCallbackPage() { console.log('GitHub installation callback:', { installationId, setupAction, state: stateParam }); + // Handle "request" action - installation pending approval + if (setupAction === 'request') { + setState('error'); + setMessage('Installation requires approval from the repository or organization owner. You can try installing on repositories you own, or wait for the owner to approve the installation.'); + return; + } + if (!installationId) { setState('error'); - setMessage('Missing installation_id parameter'); + setMessage('Installation ID not provided. The installation may not have completed successfully.'); return; } diff --git a/apps/web/client/src/app/login/actions.tsx b/apps/web/client/src/app/login/actions.tsx index e126654439..1bf2c65111 100644 --- a/apps/web/client/src/app/login/actions.tsx +++ b/apps/web/client/src/app/login/actions.tsx @@ -27,6 +27,8 @@ export async function login(provider: SignInMethod.GITHUB | SignInMethod.GOOGLE) provider, options: { redirectTo, + // Request repo scope upfront for GitHub import functionality + scopes: provider === SignInMethod.GITHUB ? 'repo read:user user:email' : undefined, }, }); diff --git a/apps/web/client/src/app/projects/import/_components/background-wrapper.tsx b/apps/web/client/src/app/projects/import/_components/background-wrapper.tsx new file mode 100644 index 0000000000..89cddc2c6d --- /dev/null +++ b/apps/web/client/src/app/projects/import/_components/background-wrapper.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { useGetBackground } from '@/hooks/use-get-background'; + +export function BackgroundWrapper({ children }: { children: React.ReactNode }) { + const backgroundUrl = useGetBackground('create'); + + return ( +
+ {children} +
+ ); +} diff --git a/apps/web/client/src/app/projects/import/cancel-button.tsx b/apps/web/client/src/app/projects/import/cancel-button.tsx deleted file mode 100644 index 0ff88ba209..0000000000 --- a/apps/web/client/src/app/projects/import/cancel-button.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Routes } from '@/utils/constants'; -import { Button } from '@onlook/ui/button'; -import { Icons } from '@onlook/ui/icons'; -import Link from 'next/link'; - -export const CancelButton = () => { - return ( - - ); -}; \ No newline at end of file diff --git a/apps/web/client/src/app/projects/import/github/_components/connect.tsx b/apps/web/client/src/app/projects/import/github/_components/connect.tsx deleted file mode 100644 index 559b91bb3b..0000000000 --- a/apps/web/client/src/app/projects/import/github/_components/connect.tsx +++ /dev/null @@ -1,116 +0,0 @@ -'use client'; - -import { Button } from '@onlook/ui/button'; -import { CardDescription, CardTitle } from '@onlook/ui/card'; -import { Icons } from '@onlook/ui/icons'; -import { Separator } from '@onlook/ui/separator'; -import { motion } from 'motion/react'; -import { StepContent, StepFooter, StepHeader } from '../../steps'; -import { useImportGithubProject } from '../_context'; - -export const ConnectGithub = () => { - const { - prevStep, - nextStep, - installation, - } = useImportGithubProject(); - - const itemContent = ({ - title, - description, - icon, - }: { - title: string; - description: string; - icon: React.ReactNode; - }) => { - return ( -
-
{icon}
-
-

{title}

-

{description}

-
-
- ); - }; - - return ( - <> - -
-
- -
- -
- -
-
- {'Connect to GitHub'} - - {'Work with real code directly in Onlook'} - -
- - - - {itemContent({ - title: installation.hasInstallation - ? 'GitHub App already connected' - : 'Install Onlook GitHub App', - description: installation.hasInstallation - ? 'You can access your repositories through the GitHub App' - : 'Get secure repository access with fine-grained permissions', - icon: installation.hasInstallation - ? - : , - })} - {installation.error && ( -
-
{installation.error}
-
- )} - -
-
- - - - {installation.hasInstallation ? ( -
- - -
- ) : ( - - )} -
- - ); -}; diff --git a/apps/web/client/src/app/projects/import/github/_components/finalizing.tsx b/apps/web/client/src/app/projects/import/github/_components/finalizing.tsx deleted file mode 100644 index 3f225a7206..0000000000 --- a/apps/web/client/src/app/projects/import/github/_components/finalizing.tsx +++ /dev/null @@ -1,48 +0,0 @@ -'use client'; - -import { Button } from '@onlook/ui/button'; -import { CardDescription, CardTitle } from '@onlook/ui/card'; -import { ProgressWithInterval } from '@onlook/ui/progress-with-interval'; -import { motion } from 'motion/react'; -import { StepContent, StepFooter, StepHeader } from '../../steps'; -import { useImportGithubProject } from '../_context'; - -export const FinalizingGithubProject = () => { - const { repositoryImport, retry, cancel } = useImportGithubProject(); - - return ( - <> - - {'Setting up project...'} - {"We're setting up your project"} - - - - {repositoryImport.error ? ( -
-

{repositoryImport.error}

-
- ) : ( - - )} -
-
- - - {repositoryImport.error && ( - - )} - - - ); -}; diff --git a/apps/web/client/src/app/projects/import/github/_components/import-flow.tsx b/apps/web/client/src/app/projects/import/github/_components/import-flow.tsx new file mode 100644 index 0000000000..bbffddefec --- /dev/null +++ b/apps/web/client/src/app/projects/import/github/_components/import-flow.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { MotionCard } from '@onlook/ui/motion-card'; +import { SetupGithub } from './setup'; + +export const ImportFlow = () => { + return ( + + + + ); +}; diff --git a/apps/web/client/src/app/projects/import/github/_components/oauth-connect.tsx b/apps/web/client/src/app/projects/import/github/_components/oauth-connect.tsx new file mode 100644 index 0000000000..8fdc13d2da --- /dev/null +++ b/apps/web/client/src/app/projects/import/github/_components/oauth-connect.tsx @@ -0,0 +1,116 @@ +'use client'; + +import { Routes } from '@/utils/constants'; +import { Button } from '@onlook/ui/button'; +import { CardDescription, CardTitle } from '@onlook/ui/card'; +import { Icons } from '@onlook/ui/icons'; +import { MotionCard } from '@onlook/ui/motion-card'; +import { Separator } from '@onlook/ui/separator'; +import { motion } from 'motion/react'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { connectGitHubRepos } from '../actions'; + +export const OAuthConnect = () => { + const router = useRouter(); + const [isConnecting, setIsConnecting] = useState(false); + + const handleConnect = async () => { + setIsConnecting(true); + try { + await connectGitHubRepos(); + } catch (error) { + console.error('Error connecting to GitHub:', error); + setIsConnecting(false); + } + }; + + const handleCancel = () => { + router.push(Routes.IMPORT_PROJECT); + }; + + return ( + +
+ {/* Header */} +
+
+
+ +
+ +
+ +
+
+ + Connect to GitHub + + + Grant Onlook access to your GitHub repositories + +
+ + + + {/* Content */} + +
+ +
+

Browse your repositories

+

+ See all repositories you have access to +

+
+
+
+ +
+

Read repository contents

+

+ Access your code to enable visual editing +

+
+
+
+ +
+

Secure OAuth access

+

+ Managed by GitHub with standard OAuth flow +

+
+
+
+ + + + {/* Footer */} +
+ + +
+
+
+ ); +}; diff --git a/apps/web/client/src/app/projects/import/github/_components/setup.tsx b/apps/web/client/src/app/projects/import/github/_components/setup.tsx index eaf284a19f..a9f5f8862d 100644 --- a/apps/web/client/src/app/projects/import/github/_components/setup.tsx +++ b/apps/web/client/src/app/projects/import/github/_components/setup.tsx @@ -5,21 +5,24 @@ import { Input } from '@onlook/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@onlook/ui/select'; import { motion } from 'motion/react'; import { useCallback, useEffect, useRef, useState } from 'react'; +import { api, type RouterOutputs } from '@/trpc/react'; +import { Routes } from '@/utils/constants'; +import { useRouter } from 'next/navigation'; import { StepContent, StepFooter, StepHeader } from '../../steps'; -import { useImportGithubProject } from '../_context'; + +type GitHubRepository = RouterOutputs['github']['getRepositoriesWithOAuth'][number]; +type GitHubOrganization = RouterOutputs['github']['getOrganizationsWithOAuth'][number]; export const SetupGithub = () => { - const { - prevStep, - selectedOrg, - setSelectedOrg, - nextStep, - selectedRepo, - setSelectedRepo, - githubData, - repositoryImport, - installation, - } = useImportGithubProject(); + const router = useRouter(); + const [selectedOrg, setSelectedOrg] = useState(null); + const [selectedRepo, setSelectedRepo] = useState(null); + + // Use tRPC hooks for data fetching + const { data: organizations = [], isLoading: isLoadingOrganizations, refetch: refetchOrganizations } = + api.github.getOrganizationsWithOAuth.useQuery(); + const { data: repositories = [], isLoading: isLoadingRepositories, refetch: refetchRepositories } = + api.github.getRepositoriesWithOAuth.useQuery(); const [searchQuery, setSearchQuery] = useState(''); const [isSearchExpanded, setIsSearchExpanded] = useState(false); @@ -33,14 +36,14 @@ export const SetupGithub = () => { if (value === 'all') { setSelectedOrg(null); } else { - const organization = githubData.organizations.find((org: any) => org.login === value); + const organization = organizations.find((org: any) => org.login === value); setSelectedOrg(organization || null); } setSelectedRepo(null); }; const handleRepositorySelect = (value: string) => { - const repository = githubData.repositories.find((repo: any) => repo.full_name === value); + const repository = repositories.find((repo: any) => repo.full_name === value); setSelectedRepo(repository || null); }; @@ -56,7 +59,7 @@ export const SetupGithub = () => { }; // Filter repositories by organization and search query - const filteredRepositories = githubData.repositories.filter((repo: any) => { + const filteredRepositories = repositories.filter((repo: any) => { const matchesOrg = selectedOrg ? repo.owner.login === selectedOrg.login : true; const matchesSearch = searchQuery.trim() === '' || repo.name.toLowerCase().includes(searchQuery.toLowerCase()) || @@ -116,7 +119,7 @@ export const SetupGithub = () => { - {githubData.isLoadingOrganizations && ( + {isLoadingOrganizations && (
Loading organizations... @@ -163,14 +166,14 @@ export const SetupGithub = () => {
{
)} - {githubData.isLoadingRepositories ? ( + {isLoadingRepositories ? (
Loading repositories... @@ -285,19 +288,27 @@ export const SetupGithub = () => { - -
- - -
+
); diff --git a/apps/web/client/src/app/projects/import/github/_context/index.tsx b/apps/web/client/src/app/projects/import/github/_context/index.tsx deleted file mode 100644 index 73c757dac5..0000000000 --- a/apps/web/client/src/app/projects/import/github/_context/index.tsx +++ /dev/null @@ -1,184 +0,0 @@ -'use client'; - -import { Routes } from '@/utils/constants'; -import type { GitHubOrganization, GitHubRepository } from '@onlook/github'; -import { useRouter } from 'next/navigation'; -import { createContext, useContext, useEffect, useState } from 'react'; -import { - useGitHubAppInstallation, - useGitHubData, - useRepositoryImport, - useRepositoryValidation, -} from '../_hooks'; - -interface ImportGithubProjectProviderProps { - children: React.ReactNode; - totalSteps: number; -} - -interface ImportGithubContextType { - // Step management - currentStep: number; - setCurrentStep: (step: number) => void; - nextStep: () => void; - prevStep: () => void; - - // Repository data - repoUrl: string; - setRepoUrl: (repoUrl: string) => void; - branch: string; - setBranch: (branch: string) => void; - selectedRepo: GitHubRepository | null; - setSelectedRepo: (repo: GitHubRepository | null) => void; - selectedOrg: GitHubOrganization | null; - setSelectedOrg: (org: GitHubOrganization | null) => void; - - // Hook instances (exposed directly) - installation: ReturnType; - githubData: ReturnType; - repositoryImport: ReturnType; - repositoryValidation: ReturnType; - - // Utility functions - validateRepository: ( - owner: string, - repo: string, - ) => Promise<{ branch: string; isPrivateRepo: boolean } | null>; - clearErrors: () => void; - retry: () => void; - cancel: () => void; -} - -export const ImportGithubProjectProvider: React.FC = ({ - children, - totalSteps = 1, -}) => { - const router = useRouter(); - - // Step management - const [currentStep, setCurrentStep] = useState(0); - - // Repository data - const [repoUrl, setRepoUrl] = useState(''); - const [branch, setBranch] = useState(''); - const [selectedRepo, setSelectedRepo] = useState(null); - const [selectedOrg, setSelectedOrg] = useState(null); - - // Hook instances - const installation = useGitHubAppInstallation(); - const githubData = useGitHubData(); - const repositoryImport = useRepositoryImport(); - const repositoryValidation = useRepositoryValidation(); - - useEffect(() => { - installation.refetch(); - }, []); - - useEffect(() => { - if (installation.hasInstallation) { - githubData.fetchOrganizations(); - githubData.fetchRepositories(); - } - }, [installation.hasInstallation]); - - const nextStep = async () => { - if (currentStep === 0 && !installation.hasInstallation) { - installation.redirectToInstallation(); - return; - } - - if (currentStep === 1) { - setCurrentStep(2); - if (selectedRepo) { - await repositoryImport.importRepository(selectedRepo); - } - } else if (currentStep < totalSteps - 1) { - setCurrentStep((prev) => prev + 1); - } - }; - - const prevStep = () => { - if (currentStep === 0) { - router.push(Routes.IMPORT_PROJECT); - return; - } - setCurrentStep((prev) => prev - 1); - }; - - const validateRepository = async (owner: string, repo: string) => { - const result = await repositoryValidation.validateRepository(owner, repo); - if (result) { - setBranch(result.branch); - } - return result; - }; - - const clearErrors = () => { - installation.clearError(); - githubData.clearErrors(); - repositoryImport.clearError(); - repositoryValidation.clearError(); - }; - - const clearData = () => { - setSelectedRepo(null); - setSelectedOrg(null); - setRepoUrl(''); - setBranch(''); - }; - - const retry = () => { - setCurrentStep(1); - }; - - const cancel = () => { - clearData(); - setCurrentStep(1); - }; - - const contextValue: ImportGithubContextType = { - // Step management - currentStep, - setCurrentStep, - nextStep, - prevStep, - - // Repository data - repoUrl, - setRepoUrl, - branch, - setBranch, - selectedRepo, - setSelectedRepo, - selectedOrg, - setSelectedOrg, - - // Hook instances (exposed directly) - installation, - githubData, - repositoryImport, - repositoryValidation, - - // Utility functions - validateRepository, - clearErrors, - retry, - cancel, - }; - - return ( - - {children} - - ); -}; - -const ImportGithubProjectContext = createContext(null); - -export const useImportGithubProject = () => { - const context = useContext(ImportGithubProjectContext); - if (!context) { - throw new Error('useImportGithubProject must be used within ImportGithubProjectProvider'); - } - return context; -}; diff --git a/apps/web/client/src/app/projects/import/github/_hooks/index.ts b/apps/web/client/src/app/projects/import/github/_hooks/index.ts deleted file mode 100644 index 7a05f42f8d..0000000000 --- a/apps/web/client/src/app/projects/import/github/_hooks/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './use-data'; -export * from './use-installation'; -export * from './use-repo-import'; -export * from './use-repo-validation'; - diff --git a/apps/web/client/src/app/projects/import/github/_hooks/use-data.ts b/apps/web/client/src/app/projects/import/github/_hooks/use-data.ts deleted file mode 100644 index db329cdc20..0000000000 --- a/apps/web/client/src/app/projects/import/github/_hooks/use-data.ts +++ /dev/null @@ -1,75 +0,0 @@ -'use client'; - -import { api as clientApi } from '@/trpc/client'; -import type { GitHubOrganization, GitHubRepository } from '@onlook/github'; -import { useState } from 'react'; - -export const useGitHubData = () => { - const [organizations, setOrganizations] = useState([]); - const [repositories, setRepositories] = useState([]); - const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false); - const [isLoadingRepositories, setIsLoadingRepositories] = useState(false); - const [organizationsError, setOrganizationsError] = useState(null); - const [repositoriesError, setRepositoriesError] = useState(null); - - const fetchOrganizations = async () => { - setIsLoadingOrganizations(true); - setOrganizationsError(null); - - try { - const organizationsData = await clientApi.github.getOrganizations.query(); - setOrganizations(organizationsData as GitHubOrganization[]); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Failed to fetch organizations'; - setOrganizationsError(errorMessage); - console.error('Error fetching organizations:', error); - } finally { - setIsLoadingOrganizations(false); - } - }; - - const fetchRepositories = async () => { - setIsLoadingRepositories(true); - setRepositoriesError(null); - - try { - const repositoriesData = await clientApi.github.getRepositoriesWithApp.query(); - setRepositories(repositoriesData as GitHubRepository[]); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Failed to fetch repositories'; - setRepositoriesError(errorMessage); - console.error('Error fetching repositories:', error); - } finally { - setIsLoadingRepositories(false); - } - }; - - const clearOrganizationsError = () => { - setOrganizationsError(null); - }; - - const clearRepositoriesError = () => { - setRepositoriesError(null); - }; - - const clearErrors = () => { - setOrganizationsError(null); - setRepositoriesError(null); - }; - - return { - organizations, - repositories, - isLoadingOrganizations, - isLoadingRepositories, - organizationsError, - repositoriesError, - fetchOrganizations, - fetchRepositories, - clearOrganizationsError, - clearRepositoriesError, - clearErrors, - }; -}; \ No newline at end of file diff --git a/apps/web/client/src/app/projects/import/github/_hooks/use-installation.ts b/apps/web/client/src/app/projects/import/github/_hooks/use-installation.ts deleted file mode 100644 index df33ed3f63..0000000000 --- a/apps/web/client/src/app/projects/import/github/_hooks/use-installation.ts +++ /dev/null @@ -1,56 +0,0 @@ -'use client'; - -import { api } from '@/trpc/react'; -import { useEffect, useState } from 'react'; - -export interface GitHubAppInstallation { - hasInstallation: boolean; - installationId: string | null; - isChecking: boolean; - error: string | null; - redirectToInstallation: (redirectUrl?: string) => Promise; - refetch: () => void; - clearError: () => void; -} - -export const useGitHubAppInstallation: () => GitHubAppInstallation = () => { - const generateInstallationUrl = api.github.generateInstallationUrl.useMutation(); - const { data: installationId, refetch: checkInstallation, isFetching: isChecking, error: checkInstallationError } = api.github.checkGitHubAppInstallation.useQuery(undefined, { - refetchOnWindowFocus: true, - }); - const [error, setError] = useState(null); - const hasInstallation = !!installationId; - - useEffect(() => { - setError(checkInstallationError?.message || null); - }, [checkInstallationError]); - - const clearError = () => { - setError(null); - }; - - const redirectToInstallation = async (redirectUrl?: string) => { - try { - const finalRedirectUrl = redirectUrl; - const result = await generateInstallationUrl.mutateAsync({ - redirectUrl: finalRedirectUrl, - }); - - if (result?.url) { - window.open(result.url, '_blank'); - } - } catch (error) { - console.error('Error generating GitHub App installation URL:', error); - } - }; - - return { - hasInstallation, - installationId: installationId || null, - isChecking, - error, - redirectToInstallation, - refetch: checkInstallation, - clearError, - }; -}; \ No newline at end of file diff --git a/apps/web/client/src/app/projects/import/github/_hooks/use-repo-import.ts b/apps/web/client/src/app/projects/import/github/_hooks/use-repo-import.ts deleted file mode 100644 index 503d196df9..0000000000 --- a/apps/web/client/src/app/projects/import/github/_hooks/use-repo-import.ts +++ /dev/null @@ -1,72 +0,0 @@ -'use client'; - -import { api as clientApi } from '@/trpc/client'; -import { api } from '@/trpc/react'; -import { Routes } from '@/utils/constants'; -import { useRouter } from 'next/navigation'; -import { useState } from 'react'; -import type { GitHubRepository } from '@onlook/github'; - -export const useRepositoryImport = () => { - const router = useRouter(); - const [isImporting, setIsImporting] = useState(false); - const [error, setError] = useState(null); - - const { data: user } = api.user.get.useQuery(); - - const importRepository = async (selectedRepo: GitHubRepository) => { - if (!user?.id) { - setError('No user found'); - return; - } - - if (!selectedRepo) { - setError('No repository selected'); - return; - } - - setIsImporting(true); - setError(null); - - try { - const { sandboxId, previewUrl } = await clientApi.sandbox.createFromGitHub.mutate({ - repoUrl: selectedRepo.clone_url, - branch: selectedRepo.default_branch, - }); - - const project = await clientApi.project.create.mutate({ - project: { - name: selectedRepo.name ?? 'New project', - description: selectedRepo.description || 'Imported from GitHub', - }, - userId: user.id, - sandboxId, - sandboxUrl: previewUrl, - }); - - if (!project) { - throw new Error('Failed to create project'); - } - - router.push(`${Routes.PROJECT}/${project.id}`); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Failed to import repository'; - setError(errorMessage); - console.error('Error importing repository:', error); - } finally { - setIsImporting(false); - } - }; - - const clearError = () => { - setError(null); - }; - - return { - isImporting, - error, - importRepository, - clearError, - }; -}; \ No newline at end of file diff --git a/apps/web/client/src/app/projects/import/github/_hooks/use-repo-validation.ts b/apps/web/client/src/app/projects/import/github/_hooks/use-repo-validation.ts deleted file mode 100644 index cb40e1e343..0000000000 --- a/apps/web/client/src/app/projects/import/github/_hooks/use-repo-validation.ts +++ /dev/null @@ -1,40 +0,0 @@ -'use client'; - -import { api } from '@/trpc/react'; -import { useState } from 'react'; - -export const useRepositoryValidation = () => { - const [isValidating, setIsValidating] = useState(false); - const [error, setError] = useState(null); - - const validateRepo = api.github.validate.useMutation(); - - const validateRepository = async (owner: string, repo: string) => { - setIsValidating(true); - setError(null); - - try { - const result = await validateRepo.mutateAsync({ owner, repo }); - return result; - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : 'Failed to validate repository'; - setError(errorMessage); - console.error('Error validating repository:', error); - return null; - } finally { - setIsValidating(false); - } - }; - - const clearError = () => { - setError(null); - }; - - return { - isValidating, - error, - validateRepository, - clearError, - }; -}; \ No newline at end of file diff --git a/apps/web/client/src/app/projects/import/github/actions.ts b/apps/web/client/src/app/projects/import/github/actions.ts new file mode 100644 index 0000000000..fac81d6cda --- /dev/null +++ b/apps/web/client/src/app/projects/import/github/actions.ts @@ -0,0 +1,39 @@ +'use server'; + +import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; + +import { env } from '@/env'; +import { Routes } from '@/utils/constants'; +import { createClient } from '@/utils/supabase/server'; + +export async function connectGitHubRepos() { + const supabase = await createClient(); + const origin = (await headers()).get('origin') ?? env.NEXT_PUBLIC_SITE_URL; + const redirectTo = `${origin}${Routes.IMPORT_GITHUB}`; + + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session) { + redirect('/login'); + } + + const { data, error } = await supabase.auth.signInWithOAuth({ + provider: 'github', + options: { + redirectTo, + scopes: 'repo', // Request repository access + skipBrowserRedirect: false, + }, + }); + + if (error) { + console.error('Error requesting GitHub repo access:', error); + redirect(`${Routes.IMPORT_GITHUB}?error=oauth_failed`); + } + + if (data?.url) { + redirect(data.url); + } +} diff --git a/apps/web/client/src/app/projects/import/github/importing/page.tsx b/apps/web/client/src/app/projects/import/github/importing/page.tsx new file mode 100644 index 0000000000..69fa227d56 --- /dev/null +++ b/apps/web/client/src/app/projects/import/github/importing/page.tsx @@ -0,0 +1,122 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; + +import { Button } from '@onlook/ui/button'; +import { Card, CardContent, CardDescription, CardTitle } from '@onlook/ui/card'; +import { Icons } from '@onlook/ui/icons'; + +import { api } from '@/trpc/react'; +import { Routes } from '@/utils/constants'; + +export default function ImportingPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [error, setError] = useState(null); + + const repoFullName = searchParams.get('repo'); + const defaultBranch = searchParams.get('branch'); + const cloneUrl = searchParams.get('clone_url'); + const name = searchParams.get('name'); + const description = searchParams.get('description'); + + const { data: user } = api.user.get.useQuery(); + const createSandbox = api.sandbox.createFromGitHub.useMutation(); + const createProject = api.project.create.useMutation(); + + useEffect(() => { + if (!repoFullName || !defaultBranch || !cloneUrl || !name || !user?.id) { + return; + } + + const importRepo = async () => { + try { + // Create sandbox from GitHub + const sandbox = await createSandbox.mutateAsync({ + repoUrl: cloneUrl, + branch: defaultBranch, + }); + + // Create project + const project = await createProject.mutateAsync({ + project: { + name: name, + description: description ?? 'Imported from GitHub', + }, + userId: user.id, + sandboxId: sandbox.sandboxId, + sandboxUrl: sandbox.previewUrl, + }); + + if (!project) { + throw new Error('Failed to create project'); + } + + // Redirect to project + router.push(`${Routes.PROJECT}/${project.id}`); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'Failed to import repository'; + setError(errorMessage); + console.error('Error importing repository:', err); + } + }; + + void importRepo(); + }, [repoFullName, defaultBranch, cloneUrl, name, description, user?.id]); + + const handleRetry = () => { + router.push(Routes.IMPORT_GITHUB); + }; + + if (error) { + return ( +
+
+ + +
+
+ +
+ Import Failed + {error} +
+
+ +
+
+
+
+
+ ); + } + + return ( +
+
+ + +
+
+ +
+ + Importing Repository + + + {repoFullName ?? 'Setting up your project...'} + +
+
+ This may take a minute. Please don't close this page. +
+
+
+
+
+ ); +} diff --git a/apps/web/client/src/app/projects/import/github/layout.tsx b/apps/web/client/src/app/projects/import/github/layout.tsx deleted file mode 100644 index ca6a6f0566..0000000000 --- a/apps/web/client/src/app/projects/import/github/layout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Routes } from '@/utils/constants'; -import { createClient } from '@/utils/supabase/server'; -import { type Metadata } from 'next'; -import { redirect } from 'next/navigation'; -import { ImportGithubProjectProvider } from './_context'; - -export const metadata: Metadata = { - title: 'Onlook', - description: 'Onlook – Import Github Project', -}; - -export default async function Layout({ children }: Readonly<{ children: React.ReactNode }>) { - const supabase = await createClient(); - const { - data: { session }, - } = await supabase.auth.getSession(); - if (!session) { - redirect(Routes.LOGIN); - } - return ( - {children} - ); -} diff --git a/apps/web/client/src/app/projects/import/github/loading.tsx b/apps/web/client/src/app/projects/import/github/loading.tsx new file mode 100644 index 0000000000..b47d4d58df --- /dev/null +++ b/apps/web/client/src/app/projects/import/github/loading.tsx @@ -0,0 +1,32 @@ +import { Card, CardContent } from '@onlook/ui/card'; +import { Skeleton } from '@onlook/ui/skeleton'; + +export default function Loading() { + return ( + + +
+ + +
+ + + +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+
+
+ ); +} diff --git a/apps/web/client/src/app/projects/import/github/page.tsx b/apps/web/client/src/app/projects/import/github/page.tsx index 8f784a3ab6..58ad5292c1 100644 --- a/apps/web/client/src/app/projects/import/github/page.tsx +++ b/apps/web/client/src/app/projects/import/github/page.tsx @@ -1,78 +1,28 @@ -'use client'; +import { api, HydrateClient } from '@/trpc/server'; +import { createClient } from '@/utils/supabase/server'; +import { ImportFlow } from './_components/import-flow'; +import { OAuthConnect } from './_components/oauth-connect'; -import { useGetBackground } from '@/hooks/use-get-background'; -import { MotionCard } from '@onlook/ui/motion-card'; -import { AnimatePresence, motion, MotionConfig } from 'motion/react'; -import useResizeObserver from 'use-resize-observer'; -import { ConnectGithub } from './_components/connect'; -import { FinalizingGithubProject } from './_components/finalizing'; -import { SetupGithub } from './_components/setup'; -import { useImportGithubProject } from './_context'; +const Page = async () => { + const supabase = await createClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); -const steps = [ - , - , - -]; + const hasOAuthAccess = !!session?.provider_token; -const Page = () => { - const { currentStep } = useImportGithubProject(); - const { ref } = useResizeObserver(); - const backgroundUrl = useGetBackground('create'); + if (!hasOAuthAccess) { + return ; + } - const variants = { - initial: (direction: number) => { - return { x: `${120 * direction}%`, opacity: 0 }; - }, - active: { x: '0%', opacity: 1 }, - exit: (direction: number) => { - return { x: `${-120 * direction}%`, opacity: 0 }; - }, - }; + void api.github.getRepositoriesWithOAuth.prefetch(); + void api.github.getOrganizationsWithOAuth.prefetch(); return ( -
-
-
-
- - - - - - {steps[currentStep]} - - - - - -
-
-
+ + + ); }; -export default Page; \ No newline at end of file +export default Page; diff --git a/apps/web/client/src/app/projects/import/layout.tsx b/apps/web/client/src/app/projects/import/layout.tsx index 3ad435da4a..e7240a1c7d 100644 --- a/apps/web/client/src/app/projects/import/layout.tsx +++ b/apps/web/client/src/app/projects/import/layout.tsx @@ -2,6 +2,8 @@ import { Routes } from '@/utils/constants'; import { createClient } from '@/utils/supabase/server'; import { type Metadata } from 'next'; import { redirect } from 'next/navigation'; +import { TopBar } from '../_components/top-bar'; +import { BackgroundWrapper } from './_components/background-wrapper'; export const metadata: Metadata = { title: 'Onlook', @@ -16,5 +18,14 @@ export default async function Layout({ children }: Readonly<{ children: React.Re if (!session) { redirect(Routes.LOGIN); } - return <>{children}; + return ( + +
+ +
+ {children} +
+
+
+ ); } diff --git a/apps/web/client/src/app/projects/import/local/_components/import-local-project.tsx b/apps/web/client/src/app/projects/import/local/_components/import-local-project.tsx deleted file mode 100644 index a57578483a..0000000000 --- a/apps/web/client/src/app/projects/import/local/_components/import-local-project.tsx +++ /dev/null @@ -1,63 +0,0 @@ -'use client'; - -import { useGetBackground } from '@/hooks/use-get-background'; -import { MotionCard } from '@onlook/ui/motion-card'; -import { AnimatePresence, motion, MotionConfig } from 'motion/react'; -import useResizeObserver from 'use-resize-observer'; -import { useProjectCreation } from '../_context'; -import { FinalizingProject } from './finalizing-project'; -import { NewSelectFolder } from './select-folder'; - -const steps = [, ]; - -export const ImportLocalProject = () => { - const { currentStep, direction } = useProjectCreation(); - const { ref } = useResizeObserver(); - - const variants = { - initial: (direction: number) => { - return { x: `${120 * direction}%`, opacity: 0 }; - }, - active: { x: '0%', opacity: 1 }, - exit: (direction: number) => { - return { x: `${-120 * direction}%`, opacity: 0 }; - }, - }; - const backgroundUrl = useGetBackground('create'); - return ( -
-
- - - - - - {steps[currentStep]} - - - - - -
-
- ); -}; diff --git a/apps/web/client/src/app/projects/import/local/_components/select-folder.tsx b/apps/web/client/src/app/projects/import/local/_components/select-folder.tsx index d473da5ed8..c0abfe7280 100644 --- a/apps/web/client/src/app/projects/import/local/_components/select-folder.tsx +++ b/apps/web/client/src/app/projects/import/local/_components/select-folder.tsx @@ -3,12 +3,14 @@ import { type NextJsProjectValidation, type ProcessedFile, } from '@/app/projects/types'; +import { Routes } from '@/utils/constants'; import { IGNORED_UPLOAD_DIRECTORIES, IGNORED_UPLOAD_FILES } from '@onlook/constants'; import { Button } from '@onlook/ui/button'; import { CardDescription, CardTitle } from '@onlook/ui/card'; import { Icons } from '@onlook/ui/icons'; import { isBinaryFile } from '@onlook/utility'; import { motion } from 'motion/react'; +import { useRouter } from 'next/navigation'; import { useCallback, useRef, useState } from 'react'; import { StepContent, StepFooter, StepHeader } from '../../steps'; import { useProjectCreation } from '../_context'; @@ -21,6 +23,7 @@ declare module 'react' { } export const NewSelectFolder = () => { + const router = useRouter(); const { projectData, setProjectData, @@ -471,7 +474,7 @@ export const NewSelectFolder = () => { {renderHeader()} {renderProjectInfo()} - {projectData.folderPath ? ( diff --git a/apps/web/client/src/app/projects/import/local/page.tsx b/apps/web/client/src/app/projects/import/local/page.tsx index d69d16ce1a..ecd16736f0 100644 --- a/apps/web/client/src/app/projects/import/local/page.tsx +++ b/apps/web/client/src/app/projects/import/local/page.tsx @@ -1,13 +1,8 @@ 'use client'; -import { useGetBackground } from '@/hooks/use-get-background'; -import { Routes } from '@/utils/constants'; -import { Icons } from '@onlook/ui/icons'; import { MotionCard } from '@onlook/ui/motion-card'; import { AnimatePresence, motion, MotionConfig } from 'motion/react'; -import Link from 'next/link'; import useResizeObserver from 'use-resize-observer'; -import { CancelButton } from '../cancel-button'; import { FinalizingProject } from './_components/finalizing-project'; import { NewSelectFolder } from './_components/select-folder'; import { useProjectCreation } from './_context'; @@ -27,54 +22,35 @@ const Page = () => { return { x: `${-120 * direction}%`, opacity: 0 }; }, }; - const backgroundUrl = useGetBackground('create'); + return ( -
-
- - - - -
-
-
- - + + + + - - - - {steps[currentStep]} - - - - - -
-
-
+ {steps[currentStep]} + + + + + ); }; diff --git a/apps/web/client/src/app/projects/import/page.tsx b/apps/web/client/src/app/projects/import/page.tsx index 0dc3da9aa7..e18eed29a8 100644 --- a/apps/web/client/src/app/projects/import/page.tsx +++ b/apps/web/client/src/app/projects/import/page.tsx @@ -1,29 +1,17 @@ 'use client'; -import { useGetBackground } from '@/hooks/use-get-background'; import { Card, CardDescription, CardHeader, CardTitle } from '@onlook/ui/card'; import { Icons } from '@onlook/ui/icons'; import { useRouter } from 'next/navigation'; -import { TopBar } from '../_components/top-bar'; const Page = () => { const router = useRouter(); const handleCardClick = (type: 'local' | 'github') => { router.push(`/projects/import/${type}`); }; - const backgroundUrl = useGetBackground('create'); - return ( -
- -
+
handleCardClick('local')} @@ -43,10 +31,9 @@ const Page = () => {
- {/* Temporary disabled */} false && handleCardClick('github')} + className={'w-full h-64 cursor-pointer transition-all duration-200 bg-background/80 backdrop-blur-xl hover:shadow-lg hover:scale-[1.02] border-[0.5px] border-foreground-tertiary/50'} + onClick={() => handleCardClick('github')} tabIndex={0} role="button" aria-label="Connect to GitHub" @@ -63,7 +50,6 @@ const Page = () => {
-
); }; diff --git a/apps/web/client/src/server/api/routers/github.ts b/apps/web/client/src/server/api/routers/github.ts index 907b5f2491..2bda4da77f 100644 --- a/apps/web/client/src/server/api/routers/github.ts +++ b/apps/web/client/src/server/api/routers/github.ts @@ -1,17 +1,19 @@ -import { users, type DrizzleDb } from '@onlook/db'; -import { - createInstallationOctokit, - generateInstallationUrl -} from '@onlook/github'; +import { Octokit } from '@octokit/rest'; import { TRPCError } from '@trpc/server'; import { eq } from 'drizzle-orm'; import { z } from 'zod'; + +import type { DrizzleDb } from '@onlook/db'; +import { users } from '@onlook/db'; +import { createInstallationOctokit, generateInstallationUrl } from '@onlook/github'; + +import { createClient } from '@/utils/supabase/server'; import { createTRPCRouter, protectedProcedure } from '../trpc'; const getUserGitHubInstallation = async (db: DrizzleDb, userId: string) => { const user = await db.query.users.findFirst({ where: eq(users.id, userId), - columns: { githubInstallationId: true } + columns: { githubInstallationId: true }, }); if (!user?.githubInstallationId) { @@ -22,7 +24,28 @@ const getUserGitHubInstallation = async (db: DrizzleDb, userId: string) => { } return { octokit: createInstallationOctokit(user.githubInstallationId), - installationId: user.githubInstallationId + installationId: user.githubInstallationId, + }; +}; + +const getUserGitHubOAuth = async () => { + const supabase = await createClient(); + const { + data: { session }, + } = await supabase.auth.getSession(); + + const providerToken = session?.provider_token; + + if (!providerToken) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'GitHub OAuth access required', + }); + } + + return { + octokit: new Octokit({ auth: providerToken }), + token: providerToken, }; }; @@ -31,7 +54,7 @@ export const githubRouter = createTRPCRouter({ .input( z.object({ owner: z.string(), - repo: z.string() + repo: z.string(), }), ) .mutation(async ({ input, ctx }) => { @@ -39,63 +62,74 @@ export const githubRouter = createTRPCRouter({ const { data } = await octokit.rest.repos.get({ owner: input.owner, repo: input.repo }); return { branch: data.default_branch, - isPrivateRepo: data.private + isPrivateRepo: data.private, }; }), getRepo: protectedProcedure .input( z.object({ owner: z.string(), - repo: z.string() + repo: z.string(), }), ) .query(async ({ input, ctx }) => { const { octokit } = await getUserGitHubInstallation(ctx.db, ctx.user.id); const { data } = await octokit.rest.repos.get({ owner: input.owner, - repo: input.repo + repo: input.repo, }); return data; }), - getOrganizations: protectedProcedure - .query(async ({ ctx }) => { - try { - const { octokit, installationId } = await getUserGitHubInstallation(ctx.db, ctx.user.id); + getOrganizations: protectedProcedure.query(async ({ ctx }) => { + try { + const { octokit, installationId } = await getUserGitHubInstallation( + ctx.db, + ctx.user.id, + ); - // Get installation details to determine account type - const installation = await octokit.rest.apps.getInstallation({ - installation_id: parseInt(installationId, 10), - }); + // Get installation details to determine account type + const installation = await octokit.rest.apps.getInstallation({ + installation_id: parseInt(installationId, 10), + }); - // If installed on an organization, return that organization - if (installation.data.account && 'type' in installation.data.account && installation.data.account.type === 'Organization') { - return [{ + // If installed on an organization, return that organization + if ( + installation.data.account && + 'type' in installation.data.account && + installation.data.account.type === 'Organization' + ) { + return [ + { id: installation.data.account.id, - login: 'login' in installation.data.account ? installation.data.account.login : (installation.data.account as any).name || '', + login: + 'login' in installation.data.account + ? installation.data.account.login + : (installation.data.account as any).name || '', avatar_url: installation.data.account.avatar_url, description: undefined, // Organizations don't have descriptions in this context - }]; - } - - // If installed on a user account, return empty (no organizations) - return []; - } catch (error) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: 'GitHub App installation is invalid or has been revoked', - cause: error - }); + }, + ]; } - }), + + // If installed on a user account, return empty (no organizations) + return []; + } catch (error) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'GitHub App installation is invalid or has been revoked', + cause: error, + }); + } + }), getRepoFiles: protectedProcedure .input( z.object({ owner: z.string(), repo: z.string(), path: z.string().default(''), - ref: z.string().optional() // branch, tag, or commit SHA - }) + ref: z.string().optional(), // branch, tag, or commit SHA + }), ) .query(async ({ input, ctx }) => { const { octokit } = await getUserGitHubInstallation(ctx.db, ctx.user.id); @@ -103,15 +137,17 @@ export const githubRouter = createTRPCRouter({ owner: input.owner, repo: input.repo, path: input.path, - ...(input.ref && { ref: input.ref }) + ...(input.ref && { ref: input.ref }), }); return data; }), generateInstallationUrl: protectedProcedure .input( - z.object({ - redirectUrl: z.string().optional(), - }).optional() + z + .object({ + redirectUrl: z.string().optional(), + }) + .optional(), ) .mutation(async ({ input, ctx }) => { const { url, state } = generateInstallationUrl({ @@ -122,34 +158,47 @@ export const githubRouter = createTRPCRouter({ return { url, state }; }), - checkGitHubAppInstallation: protectedProcedure - .query(async ({ ctx }): Promise => { + checkGitHubAppInstallation: protectedProcedure.query( + async ({ ctx }): Promise => { try { - const { octokit, installationId } = await getUserGitHubInstallation(ctx.db, ctx.user.id); + const { octokit, installationId } = await getUserGitHubInstallation( + ctx.db, + ctx.user.id, + ); await octokit.rest.apps.getInstallation({ installation_id: parseInt(installationId, 10), }); return installationId; } catch (error) { - console.error('Error checking GitHub App installation:', error); - throw new TRPCError({ - code: 'FORBIDDEN', - message: error instanceof Error ? error.message : 'GitHub App installation is invalid or has been revoked', - cause: error - }); + // If user doesn't have an installation, return null (not an error) + if (error instanceof TRPCError && error.code === 'PRECONDITION_FAILED') { + return null; + } + // For other errors (invalid installation, revoked, etc.), return null as well + // This is a "check" endpoint, so it should gracefully return null instead of throwing + console.warn( + 'GitHub App installation check failed:', + error instanceof Error ? error.message : error, + ); + return null; } - }), + }, + ), - // Repository fetching using GitHub App installation (required) getRepositoriesWithApp: protectedProcedure .input( - z.object({ - username: z.string().optional(), - }).optional() + z + .object({ + username: z.string().optional(), + }) + .optional(), ) .query(async ({ ctx }) => { try { - const { octokit, installationId } = await getUserGitHubInstallation(ctx.db, ctx.user.id); + const { octokit, installationId } = await getUserGitHubInstallation( + ctx.db, + ctx.user.id, + ); const { data } = await octokit.rest.apps.listReposAccessibleToInstallation({ installation_id: parseInt(installationId, 10), @@ -158,7 +207,7 @@ export const githubRouter = createTRPCRouter({ }); // Transform to match reference implementation pattern - return data.repositories.map(repo => ({ + return data.repositories.map((repo) => ({ id: repo.id, name: repo.name, full_name: repo.full_name, @@ -176,8 +225,9 @@ export const githubRouter = createTRPCRouter({ } catch (error) { throw new TRPCError({ code: 'FORBIDDEN', - message: 'GitHub App installation is invalid or has been revoked. Please reinstall the GitHub App.', - cause: error + message: + 'GitHub App installation is invalid or has been revoked. Please reinstall the GitHub App.', + cause: error, }); } }), @@ -187,7 +237,7 @@ export const githubRouter = createTRPCRouter({ installationId: z.string(), setupAction: z.string(), state: z.string(), - }) + }), ) .mutation(async ({ input, ctx }) => { // Validate state parameter matches current user ID for CSRF protection @@ -201,7 +251,8 @@ export const githubRouter = createTRPCRouter({ // Update user's GitHub installation ID try { - await ctx.db.update(users) + await ctx.db + .update(users) .set({ githubInstallationId: input.installationId }) .where(eq(users.id, ctx.user.id)); @@ -212,7 +263,6 @@ export const githubRouter = createTRPCRouter({ message: 'GitHub App installation completed successfully', installationId: input.installationId, }; - } catch (error) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', @@ -222,4 +272,53 @@ export const githubRouter = createTRPCRouter({ } }), -}); \ No newline at end of file + getRepositoriesWithOAuth: protectedProcedure + .input( + z + .object({ + page: z.number().default(1), + perPage: z.number().default(100), + }) + .optional(), + ) + .query(async ({ input }) => { + try { + const { octokit } = await getUserGitHubOAuth(); + + const { data } = await octokit.rest.repos.listForAuthenticatedUser({ + per_page: input?.perPage ?? 100, + page: input?.page ?? 1, + sort: 'updated', + affiliation: 'owner,collaborator,organization_member', + }); + + return data; + } catch (error) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: + 'GitHub OAuth access is invalid or has been revoked. Please reconnect your GitHub account.', + cause: error, + }); + } + }), + + getOrganizationsWithOAuth: protectedProcedure.query(async () => { + try { + const { octokit } = await getUserGitHubOAuth(); + + const { data } = await octokit.rest.orgs.listForAuthenticatedUser({ + per_page: 100, + }); + + return data; + } catch (error) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: + 'GitHub OAuth access is invalid or has been revoked. Please reconnect your GitHub account.', + cause: error, + }); + } + }), +}); diff --git a/docs/github-app-setup-testing.md b/docs/github-app-setup-testing.md new file mode 100644 index 0000000000..742573b21a --- /dev/null +++ b/docs/github-app-setup-testing.md @@ -0,0 +1,171 @@ +# GitHub App Setup for Testing + +This guide will help you create a test GitHub App to develop and test the GitHub import functionality. + +## Prerequisites + +- A GitHub account +- Access to create GitHub Apps (personal account or organization) +- Local development environment running + +## Step 1: Create a New GitHub App + +1. Go to GitHub Settings: + - **Personal account**: https://github.com/settings/apps + - **Organization**: https://github.com/organizations/YOUR_ORG/settings/apps + +2. Click **"New GitHub App"** + +3. Fill in the basic information: + - **GitHub App name**: `Onlook Test App` (or similar) + - **Homepage URL**: `http://localhost:3000` (your local dev URL) + - **Callback URL**: `http://localhost:3000/callback/github/install` + - **Setup URL**: Leave blank + - **Webhook URL**: Leave blank for now (or use ngrok for local testing) + - **Webhook secret**: Leave blank for testing + +## Step 2: Configure Permissions + +The app needs the following permissions: + +### Repository Permissions +- **Contents**: Read-only (to read repository files and clone) +- **Metadata**: Read-only (automatic, for basic repo info) + +### Account Permissions +- **Email addresses**: Read-only (to get user email) + +### Where can this GitHub App be installed? +- Select **"Any account"** for testing + +## Step 3: Generate Private Key + +1. After creating the app, scroll down to **"Private keys"** +2. Click **"Generate a private key"** +3. A `.pem` file will be downloaded - **save this securely** + +## Step 4: Note Your App Credentials + +You'll need these values: +- **App ID**: Found at the top of your app's settings page +- **Client ID**: Found in the "About" section +- **App Slug**: The URL-friendly name (e.g., `onlook-test-app`) +- **Private Key**: The `.pem` file you downloaded + +## Step 5: Convert Private Key (if needed) + +The private key needs to be in PKCS#8 format. Check the key format: + +```bash +# If the key starts with "-----BEGIN RSA PRIVATE KEY-----", convert it: +cd packages/github +bun run convert-key path/to/your-downloaded-key.pem -out path/to/converted-key.pem +``` + +## Step 6: Configure Environment Variables + +1. Copy the example env file (if you haven't already): +```bash +cp apps/web/client/.env.example apps/web/client/.env.local +``` + +2. Add your GitHub App credentials to `.env.local`: + +```bash +# GitHub App Configuration +GITHUB_APP_ID="123456" +GITHUB_APP_SLUG="onlook-test-app" +GITHUB_APP_PRIVATE_KEY="-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC... +...your full private key here (with newlines)... +-----END PRIVATE KEY-----" +``` + +**Important**: The private key must include the full multi-line string with BEGIN/END markers. + +## Step 7: Install the App + +1. Start your local development server: +```bash +bun run dev +``` + +2. Navigate to the GitHub import flow: `http://localhost:3000/projects/import/github` + +3. Click the "Connect GitHub" or "Install GitHub App" button + +4. You'll be redirected to GitHub to authorize the app + +5. Select which repositories the app can access: + - **All repositories** (for testing) + - **Only select repositories** (choose test repos) + +6. Click **"Install"** + +7. You'll be redirected back to your local app + +## Step 8: Verify Installation + +Check that the installation worked: + +1. The callback page should show success +2. Check your database - the `users` table should have a `githubInstallationId` for your user +3. Try fetching repositories in the import UI + +## Troubleshooting + +### "Invalid credentials" or "401 Unauthorized" +- Verify your App ID is correct +- Check that the private key is in PKCS#8 format +- Ensure the private key includes BEGIN/END markers + +### "Missing state parameter" +- Clear your browser cookies/cache +- Restart your dev server +- Try the installation flow again + +### "GitHub App installation required" +- The installation may not have completed +- Check GitHub Settings > Applications > Installed GitHub Apps +- Uninstall and try again + +### Private key format issues +```bash +# Check key format: +head -1 your-key.pem + +# Should see: -----BEGIN PRIVATE KEY----- +# If you see: -----BEGIN RSA PRIVATE KEY----- +# Then convert it using the convert-key script +``` + +## Testing Checklist + +Once installed, test these scenarios: + +- [ ] Connect GitHub account +- [ ] View list of repositories +- [ ] Filter by organization +- [ ] Search repositories +- [ ] Import a small public repository +- [ ] Import a small private repository +- [ ] Handle installation errors +- [ ] Uninstall and reinstall the app + +## Production Considerations + +When moving to production: + +1. Create a separate production GitHub App +2. Update callback URL to production domain +3. Store private key securely (use secrets manager) +4. Enable webhook for future features +5. Review and minimize permissions +6. Set up proper error monitoring +7. Add rate limit handling + +## Useful Links + +- GitHub Apps Documentation: https://docs.github.com/en/apps +- Testing GitHub Apps: https://docs.github.com/en/apps/creating-github-apps/setting-up-a-github-app/creating-a-github-app +- Octokit SDK (what we use): https://github.com/octokit/octokit.js diff --git a/docs/github-import-production-plan.md b/docs/github-import-production-plan.md new file mode 100644 index 0000000000..c3e6076457 --- /dev/null +++ b/docs/github-import-production-plan.md @@ -0,0 +1,315 @@ +# GitHub Import Functionality - Production Readiness Plan + +## Recent Updates + +### ✅ All-Scopes-Upfront OAuth (Completed) +**Implementation Date**: 2025-01-23 + +Implemented Vercel-style OAuth pattern - request all needed scopes during login: + +**Changes Made**: +1. **Updated Login OAuth** (`apps/web/client/src/app/login/actions.tsx:30-31`): + - GitHub login now requests: `repo read:user user:email` scopes upfront + - Simpler flow - only one OAuth approval needed + - User sees all permissions at signup/login + +2. **Simplified Connect UI** (`apps/web/client/src/app/projects/import/github/_components/connect.tsx`): + - Only shows GitHub App installation step (OAuth already done at login) + - Clean, straightforward flow: Install App → Continue + +**Benefits**: +- ✅ Simpler user flow - one OAuth approval +- ✅ All permissions visible upfront at login +- ✅ Matches Vercel/industry pattern +- ✅ No separate OAuth step needed during import + +**Next Steps**: +- Consider implementing OAuth-based repository listing (see `docs/github-oauth-setup.md` for guide) +- This would allow users to see all repos they have access to (not just installed ones) + +--- + +## Phase 1: Critical Fixes & Stability (High Priority) + +### 1.1 Pagination & Scalability +**Issue**: Only fetches first 100 repos (hardcoded limit) +- Implement pagination for `getRepositoriesWithApp` endpoint +- Add infinite scroll or "Load More" in UI (`setup.tsx:244`) +- Handle users with 100+ repositories +- **Files**: `github.ts:144-183`, `setup.tsx:59-66` + +### 1.2 Environment Configuration Validation +**Issue**: GitHub env vars are optional, can cause runtime failures +- Make GitHub App env vars required when feature is enabled +- Add startup validation in `config.ts:22-34` +- Provide clear error messages when misconfigured +- **Files**: `env.ts:60-62`, `config.ts` + +### 1.3 Timeout & Large Repository Handling +**Issue**: 30s timeout may be insufficient for large repos +- Increase timeout or make configurable +- Add progress feedback during import +- Implement streaming status updates +- **Files**: `codesandbox/index.ts:176-201`, `sandbox.ts:182-226` + +### 1.4 Error Handling & Logging +**Issue**: Console.error usage, poor error context +- Replace console.error with proper logging/telemetry +- Add structured error tracking (PostHog/Sentry integration) +- Improve error messages for user troubleshooting +- **Files**: All `_hooks/*.ts` files, `github.ts` + +## Phase 2: Feature Enhancements (Medium Priority) + +### 2.1 Branch Selection +**Issue**: Always imports default branch +- Add branch selector in setup UI +- Fetch available branches via GitHub API +- Store selected branch in context +- **Files**: `setup.tsx`, `github.ts` (add `getBranches` endpoint) + +### 2.2 Repository Validation & Preview +**Issue**: No pre-import validation +- Validate repo size before import +- Check for required files (package.json, etc.) +- Show repository structure preview +- Warn about large repositories +- **Files**: New `use-repo-preview.ts`, `github.ts` (add validation endpoint) + +### 2.3 Import State Tracking +**Issue**: No history of imports +- Track imported repositories in database +- Show import history in UI +- Enable re-sync/update functionality +- Detect duplicate imports +- **Files**: New DB schema, new tRPC router + +### 2.4 Search Improvements +**Issue**: Client-side only, fetches all repos first +- Add server-side search via GitHub API +- Debounce search queries +- Cache repository list with TTL +- **Files**: `github.ts` (modify getRepositoriesWithApp), `use-data.ts` + +## Phase 3: Polish & Optimization (Lower Priority) + +### 3.1 Installation State Management +**Issue**: Refetches on every window focus +- Implement smart caching with TTL +- Reduce unnecessary API calls +- Add manual refresh button +- **Files**: `use-installation.ts:18-20` + +### 3.2 UX Improvements +- Extend callback page auto-close from 3s to 5s +- Add "Close manually" button +- Improve loading states with skeleton screens +- Add progress indicators during import +- **Files**: `install/page.tsx:60-63`, `finalizing.tsx` + +### 3.3 Advanced Features +- Monorepo support (select specific packages) +- Commit/tag selection (not just branches) +- Bulk import multiple repos +- Private repo access verification +- Organization-wide settings + +## Phase 4: Testing & Monitoring + +### 4.1 Automated Testing +- Unit tests for GitHub API integration +- Integration tests for import flow +- E2E tests for complete user journey +- Edge case testing (large repos, rate limits, network failures) + +### 4.2 Monitoring & Observability +- Track import success/failure rates +- Monitor API latency and timeouts +- Alert on elevated error rates +- Dashboard for GitHub App health + +### 4.3 Documentation +- User-facing: How to set up GitHub App +- Developer docs: Architecture overview +- Troubleshooting guide +- Security & permissions documentation + +## Implementation Order (Suggested) + +1. **Week 1-2**: Phase 1 (Critical Fixes) +2. **Week 3-4**: Phase 2.1-2.2 (Branch selection, validation) +3. **Week 5**: Phase 2.3-2.4 (Tracking, search) +4. **Week 6**: Phase 3 (Polish) +5. **Week 7-8**: Phase 4 (Testing, monitoring) + +## Key Files to Modify + +- `apps/web/client/src/server/api/routers/github.ts` - API logic +- `apps/web/client/src/server/api/routers/project/sandbox.ts` - Import logic +- `apps/web/client/src/app/projects/import/github/_components/setup.tsx` - UI +- `apps/web/client/src/app/projects/import/github/_hooks/*` - State management +- `packages/github/src/*` - GitHub integration +- `packages/code-provider/src/providers/codesandbox/index.ts` - CodeSandbox integration + +--- + +## Code Quality Issues (To Refactor) + +### Critical Priority 🔴 + +#### 1. Hardcoded Pagination Limit (`github.ts:158-159`) +```typescript +per_page: 100, +page: 1, +``` +**Problem**: Users with 100+ repositories will never see them all. +**Impact**: Feature completely broken for power users/large orgs. +**Fix**: Implement pagination with cursor-based pagination or fetch all pages. + +#### 2. CSRF Validation Bug (`github.ts:196`) +```typescript +if (input.state && input.state !== ctx.user.id) { +``` +**Problem**: If `input.state` is empty string/falsy, validation passes! +**Impact**: Security vulnerability - CSRF protection can be bypassed. +**Fix**: Change to `if (!input.state || input.state !== ctx.user.id)` + +#### 3. Excessive API Refetching (`use-installation.ts:19`) +```typescript +refetchOnWindowFocus: true, +``` +**Problem**: Refetches every time user switches tabs/windows. +**Impact**: Hammers API unnecessarily, poor performance. +**Fix**: Remove or add `staleTime: 5 * 60 * 1000` (5 minutes). + +### High Priority 🟡 + +#### 4. getUserGitHubInstallation Design Flaw (`github.ts:11-27`) +```typescript +if (!user?.githubInstallationId) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'GitHub App installation required', + }); +} +``` +**Problem**: Forces every caller to handle exceptions for normal "not installed" case. +**Impact**: Awkward error handling, checkGitHubAppInstallation hack needed. +**Fix**: Return `null` or use Result type: `{ ok: true, data } | { ok: false, error }` + +#### 5. Unnecessary GitHub API Call (`github.ts:128-131`) +```typescript +const { octokit, installationId } = await getUserGitHubInstallation(ctx.db, ctx.user.id); +await octokit.rest.apps.getInstallation({ + installation_id: parseInt(installationId, 10), +}); +``` +**Problem**: Wastes GitHub API rate limit just to validate installation exists. +**Impact**: Rate limit exhaustion, slower response times. +**Fix**: Just return installationId from database without validation call. + +#### 6. No Rate Limit Handling +**Problem**: No handling for GitHub API rate limits (5000/hour). +**Impact**: Will fail catastrophically when rate limited. +**Fix**: +- Catch 429 responses +- Implement exponential backoff +- Show user-friendly error messages +- Cache responses + +#### 7. No Caching Strategy +**Problem**: Every query hits DB + GitHub API, even for stable data. +**Impact**: Poor performance, rate limit waste. +**Fix**: Cache organizations/repos list for 5-10 minutes. + +### Medium Priority 🟠 + +#### 8. Redundant Error State (`use-installation.ts:21-26`) +```typescript +const [error, setError] = useState(null); +useEffect(() => { + setError(checkInstallationError?.message || null); +}, [checkInstallationError]); +``` +**Problem**: Duplicates React Query's built-in error state. +**Impact**: Unnecessary complexity, potential state sync issues. +**Fix**: Use `checkInstallationError?.message` directly in return statement. + +#### 9. Silent Error Swallowing (`use-installation.ts:42-44`) +```typescript +catch (error) { + console.error('Error generating GitHub App installation URL:', error); +} +``` +**Problem**: Errors logged but never shown to user. +**Impact**: User has no feedback when installation flow fails. +**Fix**: Set error state or show toast notification. + +#### 10. Unused Parameter (`github.ts:149`) +```typescript +username: z.string().optional(), +``` +**Problem**: Accepted in schema but never used in function. +**Impact**: Misleading API, confusing for developers. +**Fix**: Remove parameter or implement filtering by username. + +#### 11. Type Casting with `as any` (`github.ts:75`) +```typescript +login: 'login' in installation.data.account ? installation.data.account.login : (installation.data.account as any).name || '', +``` +**Problem**: Type safety bypass indicates upstream type issues. +**Impact**: Will break if GitHub API changes, runtime errors. +**Fix**: Properly type GitHub API responses or use type guards. + +#### 12. console.log/error/warn Throughout +**Locations**: `github.ts:84, 140, 179, 197, 210` and all `_hooks/*.ts` +**Problem**: Unstructured logging, no correlation IDs, hard to debug production. +**Impact**: Poor observability, can't track issues in production. +**Fix**: Use structured logging (PostHog events, Sentry, or logging library). + +### Low Priority 🟢 + +#### 13. Dead Code (`use-installation.ts:34`) +```typescript +const finalRedirectUrl = redirectUrl; +``` +**Problem**: Useless variable assignment. +**Impact**: Code clutter. +**Fix**: Remove variable, use `redirectUrl` directly. + +#### 14. Redundant Null Coalescing (`use-installation.ts:49`) +```typescript +installationId: installationId || null, +``` +**Problem**: `installationId` is already `string | null` type. +**Impact**: Unnecessary operation. +**Fix**: Just return `installationId`. + +#### 15. Repeated Try-Catch Patterns +**Problem**: Same error handling boilerplate in multiple endpoints. +**Impact**: Code duplication, maintenance burden. +**Fix**: Extract to middleware or helper function. + +--- + +## Refactoring Priority Order + +1. **🔴 Critical** (Must fix before production): + - Fix pagination (Issue #1) + - Fix CSRF bug (Issue #2) + - Remove excessive refetching (Issue #3) + +2. **🟡 High** (Should fix soon): + - Refactor getUserGitHubInstallation (Issue #4) + - Remove unnecessary API call (Issue #5) + - Add rate limit handling (Issue #6) + - Add caching layer (Issue #7) + +3. **🟠 Medium** (Technical debt): + - Clean up hook error handling (Issues #8, #9) + - Fix unused parameters/types (Issues #10, #11) + - Replace console.* with structured logging (Issue #12) + +4. **🟢 Low** (Polish): + - Remove dead code (Issues #13, #14) + - Extract common patterns (Issue #15) diff --git a/docs/github-oauth-setup.md b/docs/github-oauth-setup.md new file mode 100644 index 0000000000..fafee2907b --- /dev/null +++ b/docs/github-oauth-setup.md @@ -0,0 +1,482 @@ +# GitHub OAuth + App Hybrid Setup + +This guide explains how to add OAuth for repository discovery while keeping GitHub App for actual access (like Vercel does). + +## Architecture Overview + +### Two-Token System: + +1. **OAuth Token** (Discovery) + - Used to: List all repos user can see (owned, collaborator, org member) + - Permissions: Read-only access to user's repos + - Scope: `read:user`, `repo` (read) + - Stored: Per-user in database + +2. **GitHub App Installation Token** (Access) + - Used to: Actually import/access repos + - Permissions: Contents read-only (from app configuration) + - Scope: Only repos where app is installed + - Stored: Per-user installation ID in database + +### User Flow: + +``` +1. User connects GitHub (OAuth) → Gets list of ALL repos they can see +2. User selects repo to import + ├─ Has app installed? → Import directly + └─ No app installed? → Prompt "Install app on this repo" +3. User installs app → Now can import +``` + +--- + +## Step 1: Enable OAuth in Your GitHub App + +### 1.1 Configure OAuth Settings + +1. Go to your GitHub App settings: https://github.com/settings/apps/onlook-test-app +2. Scroll to **"Identifying and authorizing users"** section +3. Fill in: + - **Callback URL**: `http://localhost:3000/api/auth/callback/github` (or your production URL) + - **Request user authorization (OAuth) during installation**: ☐ Leave **unchecked** (we want separate flows) + +### 1.2 Note Your OAuth Credentials + +From the app settings page: +- **Client ID**: Found in "About" section +- **Client Secret**: Click "Generate a new client secret" + - Copy and save it immediately (shown only once) + +--- + +## Step 2: Add Environment Variables + +Add to your `.env` file: + +```bash +# Existing GitHub App variables +GITHUB_APP_ID=2167008 +GITHUB_APP_SLUG=onlook-test-app +GITHUB_APP_PRIVATE_KEY="-----BEGIN PRIVATE KEY----- +... +-----END PRIVATE KEY-----" + +# New OAuth variables +GITHUB_CLIENT_ID=Iv1.abc123def456 +GITHUB_CLIENT_SECRET=abc123def456ghi789jkl012mno345pqr678stu +``` + +--- + +## Step 3: Update Environment Schema + +Edit `apps/web/client/src/env.ts`: + +```typescript +server: { + // ... existing vars ... + + // GitHub App + GITHUB_APP_ID: z.string().optional(), + GITHUB_APP_PRIVATE_KEY: z.string().optional(), + GITHUB_APP_SLUG: z.string().optional(), + + // GitHub OAuth (new) + GITHUB_CLIENT_ID: z.string().optional(), + GITHUB_CLIENT_SECRET: z.string().optional(), +}, + +runtimeEnv: { + // ... existing vars ... + + // GitHub + GITHUB_APP_ID: process.env.GITHUB_APP_ID, + GITHUB_APP_PRIVATE_KEY: process.env.GITHUB_APP_PRIVATE_KEY, + GITHUB_APP_SLUG: process.env.GITHUB_APP_SLUG, + + // GitHub OAuth (new) + GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID, + GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET, +} +``` + +--- + +## Step 4: Update Database Schema + +Add OAuth token storage to your users table. + +In `packages/db/src/schema/user/user.ts`: + +```typescript +export const users = pgTable('users', { + // ... existing fields ... + + githubInstallationId: text('github_installation_id'), // existing + githubAccessToken: text('github_access_token'), // new - OAuth token + githubTokenExpiry: timestamp('github_token_expiry'), // new - when token expires +}); +``` + +Run migration: +```bash +bun run db:push +``` + +--- + +## Step 5: Create OAuth Router + +Create `apps/web/client/src/server/api/routers/github-oauth.ts`: + +```typescript +import { users } from '@onlook/db'; +import { TRPCError } from '@trpc/server'; +import { eq } from 'drizzle-orm'; +import { Octokit } from '@octokit/rest'; +import { z } from 'zod'; +import { createTRPCRouter, protectedProcedure } from '../trpc'; +import { env } from '@/env'; + +export const githubOAuthRouter = createTRPCRouter({ + // Generate OAuth authorization URL + getAuthUrl: protectedProcedure + .input(z.object({ redirectUrl: z.string().optional() }).optional()) + .mutation(async ({ ctx, input }) => { + const params = new URLSearchParams({ + client_id: env.GITHUB_CLIENT_ID!, + redirect_uri: `${env.NEXT_PUBLIC_SITE_URL}/api/auth/callback/github`, + scope: 'read:user,repo', // repo scope for read-only access + state: ctx.user.id, // CSRF protection + ...(input?.redirectUrl && { redirect_uri: input.redirectUrl }), + }); + + const url = `https://github.com/login/oauth/authorize?${params.toString()}`; + return { url }; + }), + + // Exchange code for access token + handleCallback: protectedProcedure + .input(z.object({ + code: z.string(), + state: z.string(), + })) + .mutation(async ({ ctx, input }) => { + // Verify state matches user ID (CSRF protection) + if (input.state !== ctx.user.id) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Invalid state parameter', + }); + } + + // Exchange code for access token + const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ + client_id: env.GITHUB_CLIENT_ID, + client_secret: env.GITHUB_CLIENT_SECRET, + code: input.code, + redirect_uri: `${env.NEXT_PUBLIC_SITE_URL}/api/auth/callback/github`, + }), + }); + + const tokenData = await tokenResponse.json(); + + if (tokenData.error) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: tokenData.error_description || 'Failed to get access token', + }); + } + + // Store token in database + await ctx.db.update(users) + .set({ + githubAccessToken: tokenData.access_token, + // OAuth tokens don't expire by default, but you can add expiry if using refresh tokens + }) + .where(eq(users.id, ctx.user.id)); + + return { success: true }; + }), + + // Get all repos user has access to (using OAuth token) + getAllRepositories: protectedProcedure + .input(z.object({ + type: z.enum(['all', 'owner', 'member', 'collaborator']).default('all'), + sort: z.enum(['created', 'updated', 'pushed', 'full_name']).default('updated'), + per_page: z.number().min(1).max(100).default(30), + page: z.number().min(1).default(1), + }).optional()) + .query(async ({ ctx, input }) => { + // Get user's OAuth token + const user = await ctx.db.query.users.findFirst({ + where: eq(users.id, ctx.user.id), + columns: { + githubAccessToken: true, + githubInstallationId: true, + }, + }); + + if (!user?.githubAccessToken) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'GitHub OAuth not connected. Please connect your GitHub account.', + }); + } + + // Create Octokit with OAuth token (NOT app token) + const octokit = new Octokit({ + auth: user.githubAccessToken, + }); + + // Get all repos user has access to + const { data: repos } = await octokit.rest.repos.listForAuthenticatedUser({ + type: input?.type || 'all', + sort: input?.sort || 'updated', + per_page: input?.per_page || 30, + page: input?.page || 1, + }); + + // Check which repos have app installed (by comparing with installation repos) + let installationRepos: string[] = []; + if (user.githubInstallationId) { + try { + // This would require app token - keeping it simple for now + // In production, you'd check installation repo access + installationRepos = []; // TODO: implement checking + } catch (error) { + console.warn('Could not check installation repos:', error); + } + } + + // Transform and mark which repos have app installed + return repos.map(repo => ({ + id: repo.id, + name: repo.name, + full_name: repo.full_name, + description: repo.description, + private: repo.private, + default_branch: repo.default_branch, + clone_url: repo.clone_url, + html_url: repo.html_url, + updated_at: repo.updated_at, + owner: { + login: repo.owner.login, + avatar_url: repo.owner.avatar_url, + }, + permissions: repo.permissions, + // Mark if app is installed on this repo + hasAppInstalled: user.githubInstallationId ? installationRepos.includes(repo.full_name) : false, + })); + }), + + // Check OAuth connection status + checkOAuthConnection: protectedProcedure + .query(async ({ ctx }) => { + const user = await ctx.db.query.users.findFirst({ + where: eq(users.id, ctx.user.id), + columns: { githubAccessToken: true }, + }); + + return { + isConnected: !!user?.githubAccessToken, + }; + }), + + // Revoke OAuth token + revokeOAuth: protectedProcedure + .mutation(async ({ ctx }) => { + await ctx.db.update(users) + .set({ githubAccessToken: null }) + .where(eq(users.id, ctx.user.id)); + + return { success: true }; + }), +}); +``` + +--- + +## Step 6: Add OAuth Router to Root + +Edit `apps/web/client/src/server/api/root.ts`: + +```typescript +import { githubRouter } from './routers/github'; +import { githubOAuthRouter } from './routers/github-oauth'; // new + +export const appRouter = createTRPCRouter({ + // ... existing routers ... + github: githubRouter, + githubOAuth: githubOAuthRouter, // new +}); +``` + +--- + +## Step 7: Create OAuth Callback Route + +Create `apps/web/client/src/app/api/auth/callback/github/route.ts`: + +```typescript +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const code = searchParams.get('code'); + const state = searchParams.get('state'); + const error = searchParams.get('error'); + + if (error) { + return NextResponse.redirect( + `${process.env.NEXT_PUBLIC_SITE_URL}/projects/import/github?error=${error}` + ); + } + + if (!code || !state) { + return NextResponse.redirect( + `${process.env.NEXT_PUBLIC_SITE_URL}/projects/import/github?error=missing_params` + ); + } + + // Redirect to a page that will handle the token exchange via tRPC + return NextResponse.redirect( + `${process.env.NEXT_PUBLIC_SITE_URL}/projects/import/github/oauth-callback?code=${code}&state=${state}` + ); +} +``` + +Create `apps/web/client/src/app/projects/import/github/oauth-callback/page.tsx`: + +```typescript +'use client'; + +import { api } from '@/trpc/react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect } from 'react'; + +export default function OAuthCallbackPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const exchangeToken = api.githubOAuth.handleCallback.useMutation(); + + useEffect(() => { + const code = searchParams.get('code'); + const state = searchParams.get('state'); + + if (code && state) { + exchangeToken.mutate( + { code, state }, + { + onSuccess: () => { + router.push('/projects/import/github'); + }, + onError: (error) => { + console.error('OAuth failed:', error); + router.push('/projects/import/github?error=oauth_failed'); + }, + } + ); + } + }, []); + + return ( +
+
+

Connecting to GitHub...

+

Please wait

+
+
+ ); +} +``` + +--- + +## Step 8: Update UI to Use OAuth + +Now in your GitHub import UI, you'll have TWO connection states: + +1. **OAuth Connected** - Can browse all repos +2. **App Installed** - Can actually import repos + +Example updated context: + +```typescript +// In _context/index.tsx +const { data: oauthStatus } = api.githubOAuth.checkOAuthConnection.useQuery(); +const { data: appInstallation } = api.github.checkGitHubAppInstallation.useQuery(); + +// Show different UI based on connection state: +// - No OAuth: Show "Connect GitHub" button +// - OAuth but no App: Show repos with "Install App" button on each +// - OAuth + App: Show repos, can import any with app installed +``` + +--- + +## Usage Flow + +### For Users: + +1. **Connect GitHub (OAuth)** + ```typescript + const connectGitHub = async () => { + const { url } = await api.githubOAuth.getAuthUrl.mutate(); + window.location.href = url; + }; + ``` + +2. **Browse All Repos** + ```typescript + const { data: allRepos } = api.githubOAuth.getAllRepositories.useQuery(); + // Shows ALL repos user has access to + ``` + +3. **Import Repo** + ```typescript + if (repo.hasAppInstalled) { + // Import directly + await importRepo(repo); + } else { + // Prompt to install app + const { url } = await api.github.generateInstallationUrl.mutate(); + window.open(url); + } + ``` + +--- + +## Benefits of Hybrid Approach + +✅ **Better Discovery**: Users see all repos they have access to +✅ **Clearer UX**: "You need to install the app on this repo" +✅ **Flexible**: Works with repos user doesn't own (as collaborator) +✅ **Secure**: Still uses App tokens for actual access +✅ **Like Vercel**: Industry-standard pattern + +--- + +## Security Considerations + +- 🔒 OAuth tokens stored encrypted in database +- 🔒 State parameter for CSRF protection +- 🔒 Separate scopes for discovery vs access +- 🔒 Users can revoke OAuth independently of App +- 🔒 OAuth tokens used ONLY for listing, not for importing + +--- + +## Next Steps + +1. ✅ Complete all setup steps above +2. ✅ Test OAuth flow end-to-end +3. ✅ Update UI to show all repos with install status +4. ✅ Add "Install App" buttons for repos without installation +5. ✅ Test importing with OAuth + App combination diff --git a/packages/github/README.md b/packages/github/README.md index f9bc363333..eca26c51a5 100644 --- a/packages/github/README.md +++ b/packages/github/README.md @@ -1,23 +1,148 @@ # @onlook/github -GitHub integration package for Onlook. +GitHub integration package for Onlook that enables importing repositories from GitHub. -## Setup +## Creating a GitHub App -### GitHub App Configuration +### Step 1: Create New GitHub App -You need to set these environment variables: +1. Go to GitHub Settings: + - **Personal account**: https://github.com/settings/apps + - **Organization**: https://github.com/organizations/YOUR_ORG/settings/apps -- `GITHUB_APP_ID` - Your GitHub App's ID -- `GITHUB_APP_PRIVATE_KEY` - Your GitHub App's private key (PKCS#8 format) -- `GITHUB_APP_SLUG` - Your GitHub App's slug name +2. Click **"New GitHub App"** -### Private Key Format +3. Fill in basic information: + - **GitHub App name**: `Onlook` (or `Onlook Dev` for testing) + - **Homepage URL**: Your production URL or `http://localhost:3000` for local dev + - **Callback URL**: `https://yourdomain.com/callback/github/install` (or `http://localhost:3000/callback/github/install` for local) + - **Setup URL**: `https://yourdomain.com/callback/github/install` (or `http://localhost:3000/callback/github/install` for local) + - ✅ **Check** "Redirect on update" + - **Webhook**: Leave unchecked/inactive for now + - **Webhook URL**: Leave blank + - **Webhook secret**: Leave blank -The GitHub App private key must be in PKCS#8 format. If you have a PKCS#1 key (starts with `-----BEGIN RSA PRIVATE KEY-----`), convert it using: +### Step 2: Configure Permissions + +Set these permissions (all others should be "No access"): + +#### Repository Permissions +- ✅ **Contents**: **Read-only** +- ✅ **Metadata**: **Read-only** (automatic) + +#### Organization Permissions +- ❌ All: **No access** + +#### Account Permissions +- ❌ All: **No access** + +### Step 3: Configure Installation + +- **Where can this GitHub App be installed?** + - Select **"Any account"** (recommended) + - Or **"Only on this account"** for testing + +### Step 4: Post-Installation Settings + +- ☑️ **Expire user authorization tokens**: Checked (recommended) +- ☐ **Request user authorization (OAuth) during installation**: Unchecked +- ☐ **Enable Device Flow**: Unchecked + +### Step 5: Webhooks & Events + +- ☐ **Active**: Unchecked (no webhooks needed for basic import) +- **Subscribe to events**: Leave all unchecked + +### Step 6: Generate Private Key + +1. After creating the app, scroll to **"Private keys"** section +2. Click **"Generate a private key"** +3. Save the downloaded `.pem` file securely + +### Step 7: Note Your Credentials + +From the GitHub App settings page, copy: +- **App ID** (at the top of the page) +- **App Slug** (in the URL: `github.com/apps/YOUR-SLUG-HERE`) +- **Private Key** (the `.pem` file you downloaded) + +--- + +## Environment Configuration + +### Step 1: Convert Private Key + +The private key must be in PKCS#8 format. Check the format: ```bash -bun run convert-key path/to/your-key.pem -out path/to/converted-key.pem +head -1 /path/to/your-key.pem ``` -Then use the contents of the converted key for the `GITHUB_APP_PRIVATE_KEY` environment variable. \ No newline at end of file +If it shows `-----BEGIN RSA PRIVATE KEY-----`, convert it: + +```bash +cd packages/github +openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt \ + -in /path/to/your-key.pem \ + -out /path/to/converted-key.pem +``` + +Verify the converted key shows `-----BEGIN PRIVATE KEY-----`: + +```bash +head -1 /path/to/converted-key.pem +``` + +### Step 2: Add to Environment Variables + +Add these to your `.env` or `.env.local` file: + +```bash +# GitHub App Configuration +GITHUB_APP_ID="123456" +GITHUB_APP_SLUG="your-app-slug" +GITHUB_APP_PRIVATE_KEY="-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC... +...your full private key here (with newlines)... +-----END PRIVATE KEY-----" +``` + +**Important**: +- The private key must include the full multi-line string with BEGIN/END markers +- Keep the quotes around the key value +- Delete the `.pem` files after adding to environment variables + +## Architecture + +This package uses GitHub App authentication (installation tokens), not OAuth user tokens: + +- **Installation Authentication**: App authenticates as itself to access repositories +- **Installation ID**: Stored per-user in the database +- **Octokit**: GitHub REST API client with App authentication +- **Permissions**: Scoped to only what the app needs (read-only repository contents) + +Key files: +- `src/auth.ts` - Creates authenticated Octokit instances +- `src/config.ts` - Validates GitHub App configuration +- `src/installation.ts` - Handles installation URL generation and callbacks +- `src/types.ts` - TypeScript types for GitHub resources + +--- + +## Security Notes + +- 🔒 Private keys should never be committed to source control +- 🔒 Use environment variables or secrets managers for credentials +- 🔒 The app uses installation authentication, not user OAuth tokens +- 🔒 State parameter is used for CSRF protection in callbacks +- 🔒 Minimum permissions principle (only read-only repository contents) +- 🔒 Installation can be revoked at any time by the user on GitHub + +--- + +## Useful Links + +- [GitHub Apps Documentation](https://docs.github.com/en/apps) +- [Creating a GitHub App](https://docs.github.com/en/apps/creating-github-apps) +- [Octokit SDK](https://github.com/octokit/octokit.js) +- [GitHub App Authentication](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app) \ No newline at end of file diff --git a/packages/github/src/index.ts b/packages/github/src/index.ts index 902681f82b..9407bd45f4 100644 --- a/packages/github/src/index.ts +++ b/packages/github/src/index.ts @@ -1,4 +1,4 @@ export * from './auth'; export * from './config'; export * from './installation'; -export * from './types'; +// Types removed - use tRPC inferred types instead diff --git a/packages/github/src/types.ts b/packages/github/src/types.ts deleted file mode 100644 index 5316561027..0000000000 --- a/packages/github/src/types.ts +++ /dev/null @@ -1,22 +0,0 @@ -export interface GitHubOrganization { - id: number; - login: string; - avatar_url: string; - description?: string; -} - -export interface GitHubRepository { - id: number; - name: string; - full_name: string; - description?: string; - private: boolean; - default_branch: string; - clone_url: string; - html_url: string; - updated_at: string; - owner: { - login: string; - avatar_url: string; - }; -} \ No newline at end of file From c2446396b5e6801dd3a63f08b57b6584754c4422 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 23 Oct 2025 23:23:34 -0700 Subject: [PATCH 2/2] Wip - looks guud --- apps/backend/supabase/config.toml | 1 + .../import/github/_components/import-flow.tsx | 3 +- .../github/_components/oauth-connect.tsx | 180 +++++++++--------- .../import/github/_components/setup.tsx | 103 ++++++---- .../src/app/projects/import/github/actions.ts | 24 ++- .../src/app/projects/import/github/page.tsx | 10 +- .../src/app/projects/import/local/page.tsx | 12 +- .../client/src/app/projects/import/page.tsx | 84 ++++---- .../client/src/server/api/routers/github.ts | 74 +++++-- 9 files changed, 277 insertions(+), 214 deletions(-) diff --git a/apps/backend/supabase/config.toml b/apps/backend/supabase/config.toml index b5438ff440..c1d31d746f 100644 --- a/apps/backend/supabase/config.toml +++ b/apps/backend/supabase/config.toml @@ -14,6 +14,7 @@ additional_redirect_urls = [ "http://localhost:3000/auth/callback", ] jwt_expiry = 36000 +enable_manual_linking = true [db] port = 54322 diff --git a/apps/web/client/src/app/projects/import/github/_components/import-flow.tsx b/apps/web/client/src/app/projects/import/github/_components/import-flow.tsx index bbffddefec..1dd808c238 100644 --- a/apps/web/client/src/app/projects/import/github/_components/import-flow.tsx +++ b/apps/web/client/src/app/projects/import/github/_components/import-flow.tsx @@ -1,6 +1,7 @@ 'use client'; import { MotionCard } from '@onlook/ui/motion-card'; + import { SetupGithub } from './setup'; export const ImportFlow = () => { @@ -8,7 +9,7 @@ export const ImportFlow = () => { diff --git a/apps/web/client/src/app/projects/import/github/_components/oauth-connect.tsx b/apps/web/client/src/app/projects/import/github/_components/oauth-connect.tsx index 8fdc13d2da..b13cf5c550 100644 --- a/apps/web/client/src/app/projects/import/github/_components/oauth-connect.tsx +++ b/apps/web/client/src/app/projects/import/github/_components/oauth-connect.tsx @@ -1,116 +1,114 @@ 'use client'; -import { Routes } from '@/utils/constants'; +import { useRouter } from 'next/navigation'; +import localforage from 'localforage'; +import { motion } from 'motion/react'; + import { Button } from '@onlook/ui/button'; import { CardDescription, CardTitle } from '@onlook/ui/card'; import { Icons } from '@onlook/ui/icons'; import { MotionCard } from '@onlook/ui/motion-card'; import { Separator } from '@onlook/ui/separator'; -import { motion } from 'motion/react'; -import { useRouter } from 'next/navigation'; -import { useState } from 'react'; -import { connectGitHubRepos } from '../actions'; -export const OAuthConnect = () => { +import { LocalForageKeys, Routes } from '@/utils/constants'; +import { connectGitHubOAuth } from '../actions'; + +type OAuthConnectProps = { + error?: string; +}; + +export const OAuthConnect = ({ error }: OAuthConnectProps) => { const router = useRouter(); - const [isConnecting, setIsConnecting] = useState(false); const handleConnect = async () => { - setIsConnecting(true); - try { - await connectGitHubRepos(); - } catch (error) { - console.error('Error connecting to GitHub:', error); - setIsConnecting(false); - } - }; - - const handleCancel = () => { - router.push(Routes.IMPORT_PROJECT); + await localforage.setItem(LocalForageKeys.RETURN_URL, Routes.IMPORT_GITHUB); + await connectGitHubOAuth(); }; return ( -
- {/* Header */} -
-
-
- -
- -
- -
-
- - Connect to GitHub - - - Grant Onlook access to your GitHub repositories - -
+
+ {/* Header */} +
+
+
+ +
+ +
+ +
+
+ Connect to GitHub + + Grant Onlook access to your GitHub repositories + +
+ + - + {/* Content */} + +
+ +
+

Browse your repositories

+

See all repositories you have access to

+
+
+
+ +
+

Read repository contents

+

+ Access your code to enable visual editing +

+
+
+
+ +
+

Secure OAuth access

+

+ Managed by GitHub with standard OAuth flow +

+
+
+
- {/* Content */} - -
- -
-

Browse your repositories

-

- See all repositories you have access to -

-
-
-
- -
-

Read repository contents

-

- Access your code to enable visual editing -

-
-
-
- -
-

Secure OAuth access

-

- Managed by GitHub with standard OAuth flow -

-
-
-
+ - + {/* Error Message */} + {error && ( +
+ +

+ {error === 'oauth_failed' + ? 'Failed to connect to GitHub. Please try again.' + : 'An error occurred. Please try again.'} +

+
+ )} - {/* Footer */} -
- - -
-
+ {/* Footer */} +
+ + +
+
); }; diff --git a/apps/web/client/src/app/projects/import/github/_components/setup.tsx b/apps/web/client/src/app/projects/import/github/_components/setup.tsx index a9f5f8862d..c85029d398 100644 --- a/apps/web/client/src/app/projects/import/github/_components/setup.tsx +++ b/apps/web/client/src/app/projects/import/github/_components/setup.tsx @@ -10,20 +10,26 @@ import { Routes } from '@/utils/constants'; import { useRouter } from 'next/navigation'; import { StepContent, StepFooter, StepHeader } from '../../steps'; -type GitHubRepository = RouterOutputs['github']['getRepositoriesWithOAuth'][number]; -type GitHubOrganization = RouterOutputs['github']['getOrganizationsWithOAuth'][number]; +type GitHubData = RouterOutputs['github']['getRepositoriesWithOAuth']; +type GitHubRepository = GitHubData['repos'][number]; +type GitHubOrganization = GitHubData['organizations'][number]; export const SetupGithub = () => { const router = useRouter(); const [selectedOrg, setSelectedOrg] = useState(null); const [selectedRepo, setSelectedRepo] = useState(null); + const [importError, setImportError] = useState(null); + const [isValidating, setIsValidating] = useState(false); - // Use tRPC hooks for data fetching - const { data: organizations = [], isLoading: isLoadingOrganizations, refetch: refetchOrganizations } = - api.github.getOrganizationsWithOAuth.useQuery(); - const { data: repositories = [], isLoading: isLoadingRepositories, refetch: refetchRepositories } = + // Use tRPC hook for data fetching - organizations are derived from repos + const { data, isLoading: isLoadingRepositories, refetch: refetchRepositories } = api.github.getRepositoriesWithOAuth.useQuery(); + const repositories = data?.repos ?? []; + const organizations = data?.organizations ?? []; + + const validateRepo = api.github.validateWithOAuth.useMutation(); + const [searchQuery, setSearchQuery] = useState(''); const [isSearchExpanded, setIsSearchExpanded] = useState(false); const [canScrollUp, setCanScrollUp] = useState(false); @@ -33,18 +39,50 @@ export const SetupGithub = () => { const scrollContainerRef = useRef(null); const handleOrganizationSelect = (value: string) => { - if (value === 'all') { - setSelectedOrg(null); - } else { - const organization = organizations.find((org: any) => org.login === value); - setSelectedOrg(organization || null); - } + const organization = organizations.find((org: any) => org.login === value); + setSelectedOrg(organization || null); setSelectedRepo(null); }; const handleRepositorySelect = (value: string) => { const repository = repositories.find((repo: any) => repo.full_name === value); setSelectedRepo(repository || null); + setImportError(null); // Clear any previous errors + }; + + const handleImport = async () => { + if (!selectedRepo) return; + + setIsValidating(true); + setImportError(null); + + try { + const [owner, repo] = selectedRepo.full_name.split('/'); + if (!owner || !repo) { + throw new Error('Invalid repository name'); + } + + // Validate repository access + await validateRepo.mutateAsync({ owner, repo }); + + // If validation succeeds, navigate to importing page + const params = new URLSearchParams({ + repo: selectedRepo.full_name, + branch: selectedRepo.default_branch, + clone_url: selectedRepo.clone_url, + name: selectedRepo.name, + ...(selectedRepo.description && { description: selectedRepo.description }), + }); + router.push(`${Routes.IMPORT_GITHUB}/importing?${params.toString()}`); + } catch (error) { + const errorMessage = + error instanceof Error + ? error.message + : 'Failed to validate repository access'; + setImportError(errorMessage); + } finally { + setIsValidating(false); + } }; // Handle search toggle @@ -60,7 +98,7 @@ export const SetupGithub = () => { // Filter repositories by organization and search query const filteredRepositories = repositories.filter((repo: any) => { - const matchesOrg = selectedOrg ? repo.owner.login === selectedOrg.login : true; + const matchesOrg = !selectedOrg || repo.owner.login === selectedOrg.login; const matchesSearch = searchQuery.trim() === '' || repo.name.toLowerCase().includes(searchQuery.toLowerCase()) || repo.full_name.toLowerCase().includes(searchQuery.toLowerCase()) || @@ -81,6 +119,13 @@ export const SetupGithub = () => { updateScrollIndicators(); }, [filteredRepositories, updateScrollIndicators]); + // Default to first organization when organizations load + useEffect(() => { + if (organizations.length > 0 && !selectedOrg && organizations[0]) { + setSelectedOrg(organizations[0]); + } + }, [organizations, selectedOrg]); + // Handle click outside to close search useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -114,23 +159,17 @@ export const SetupGithub = () => {
- {isLoadingOrganizations && ( -
- - Loading organizations... -
- )}
@@ -166,14 +194,13 @@ export const SetupGithub = () => {
{
- + {canScrollUp && (
diff --git a/apps/web/client/src/app/projects/import/github/actions.ts b/apps/web/client/src/app/projects/import/github/actions.ts index fac81d6cda..2cca5631b7 100644 --- a/apps/web/client/src/app/projects/import/github/actions.ts +++ b/apps/web/client/src/app/projects/import/github/actions.ts @@ -3,33 +3,31 @@ import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; -import { env } from '@/env'; import { Routes } from '@/utils/constants'; import { createClient } from '@/utils/supabase/server'; -export async function connectGitHubRepos() { +export async function connectGitHubOAuth() { const supabase = await createClient(); - const origin = (await headers()).get('origin') ?? env.NEXT_PUBLIC_SITE_URL; - const redirectTo = `${origin}${Routes.IMPORT_GITHUB}`; - - const { - data: { session }, - } = await supabase.auth.getSession(); - if (!session) { - redirect('/login'); + const origin = (await headers()).get('origin'); + + if (!origin) { + throw new Error('Origin header not found'); } - const { data, error } = await supabase.auth.signInWithOAuth({ + const redirectTo = `${origin}${Routes.AUTH_CALLBACK}`; + + // Link GitHub provider to existing account (not create new account) + const { data, error } = await supabase.auth.linkIdentity({ provider: 'github', options: { redirectTo, - scopes: 'repo', // Request repository access + scopes: 'repo read:user user:email', skipBrowserRedirect: false, }, }); if (error) { - console.error('Error requesting GitHub repo access:', error); + console.error('Error linking GitHub identity:', error); redirect(`${Routes.IMPORT_GITHUB}?error=oauth_failed`); } diff --git a/apps/web/client/src/app/projects/import/github/page.tsx b/apps/web/client/src/app/projects/import/github/page.tsx index 58ad5292c1..caa9245787 100644 --- a/apps/web/client/src/app/projects/import/github/page.tsx +++ b/apps/web/client/src/app/projects/import/github/page.tsx @@ -3,7 +3,12 @@ import { createClient } from '@/utils/supabase/server'; import { ImportFlow } from './_components/import-flow'; import { OAuthConnect } from './_components/oauth-connect'; -const Page = async () => { +type PageProps = { + searchParams: Promise<{ error?: string }>; +}; + +const Page = async (props: PageProps) => { + const searchParams = await props.searchParams; const supabase = await createClient(); const { data: { session }, @@ -12,11 +17,10 @@ const Page = async () => { const hasOAuthAccess = !!session?.provider_token; if (!hasOAuthAccess) { - return ; + return ; } void api.github.getRepositoriesWithOAuth.prefetch(); - void api.github.getOrganizationsWithOAuth.prefetch(); return ( diff --git a/apps/web/client/src/app/projects/import/local/page.tsx b/apps/web/client/src/app/projects/import/local/page.tsx index ecd16736f0..0096ebf320 100644 --- a/apps/web/client/src/app/projects/import/local/page.tsx +++ b/apps/web/client/src/app/projects/import/local/page.tsx @@ -1,8 +1,10 @@ 'use client'; -import { MotionCard } from '@onlook/ui/motion-card'; import { AnimatePresence, motion, MotionConfig } from 'motion/react'; import useResizeObserver from 'use-resize-observer'; + +import { MotionCard } from '@onlook/ui/motion-card'; + import { FinalizingProject } from './_components/finalizing-project'; import { NewSelectFolder } from './_components/select-folder'; import { useProjectCreation } from './_context'; @@ -29,14 +31,10 @@ const Page = () => { initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: 20 }} - className="w-[30rem] min-h-[12rem] overflow-hidden p-0 border border-primary/20 rounded-lg shadow-lg !bg-background" + className="border-primary/20 !bg-background min-h-[12rem] w-[30rem] overflow-hidden rounded-lg border p-0 shadow-lg" > - + { const router = useRouter(); @@ -11,45 +12,48 @@ const Page = () => { }; return ( -
- handleCardClick('local')} - tabIndex={0} - role="button" - aria-label="Import local project" - > - -
- -
-
- Import a Local Project - - Select a directory from your computer to start working with your project in Onlook. - -
-
-
- handleCardClick('github')} - tabIndex={0} - role="button" - aria-label="Connect to GitHub" - > - -
- -
-
- Import from GitHub - - Connect your GitHub account to access and work with your repositories - -
-
-
+
+ handleCardClick('local')} + tabIndex={0} + role="button" + aria-label="Import local project" + > + +
+ +
+
+ Import a Local Project + + Select a directory from your computer to start working with your project + in Onlook. + +
+
+
+ handleCardClick('github')} + tabIndex={0} + role="button" + aria-label="Connect to GitHub" + > + +
+ +
+
+ Import from GitHub + + Connect your GitHub account to access and work with your repositories + +
+
+
); }; diff --git a/apps/web/client/src/server/api/routers/github.ts b/apps/web/client/src/server/api/routers/github.ts index 2bda4da77f..465a5d4fe2 100644 --- a/apps/web/client/src/server/api/routers/github.ts +++ b/apps/web/client/src/server/api/routers/github.ts @@ -65,6 +65,33 @@ export const githubRouter = createTRPCRouter({ isPrivateRepo: data.private, }; }), + validateWithOAuth: protectedProcedure + .input( + z.object({ + owner: z.string(), + repo: z.string(), + }), + ) + .mutation(async ({ input }) => { + try { + const { octokit } = await getUserGitHubOAuth(); + const { data } = await octokit.rest.repos.get({ + owner: input.owner, + repo: input.repo, + }); + return { + branch: data.default_branch, + isPrivateRepo: data.private, + hasAccess: true, + }; + } catch (error) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Unable to access this repository. Please check your permissions.', + cause: error, + }); + } + }), getRepo: protectedProcedure .input( z.object({ @@ -285,14 +312,38 @@ export const githubRouter = createTRPCRouter({ try { const { octokit } = await getUserGitHubOAuth(); - const { data } = await octokit.rest.repos.listForAuthenticatedUser({ + const { data: user } = await octokit.rest.users.getAuthenticated(); + + const { data: allRepos } = await octokit.rest.repos.listForAuthenticatedUser({ per_page: input?.perPage ?? 100, page: input?.page ?? 1, sort: 'updated', affiliation: 'owner,collaborator,organization_member', }); - return data; + // Filter to only include repos owned by the user or organizations + // Exclude repos owned by other individual users + const repos = allRepos.filter((repo) => { + const isOwnedByAuthUser = repo.owner.login === user.login; + const isOrganization = repo.owner.type === 'Organization'; + return isOwnedByAuthUser || isOrganization; + }); + + // Extract unique organizations from repository owners + const ownerMap = new Map(); + repos.forEach((repo) => { + if (!ownerMap.has(repo.owner.login)) { + ownerMap.set(repo.owner.login, { + id: repo.owner.id, + login: repo.owner.login, + avatar_url: repo.owner.avatar_url, + }); + } + }); + + const organizations = Array.from(ownerMap.values()); + + return { repos, organizations }; } catch (error) { throw new TRPCError({ code: 'FORBIDDEN', @@ -302,23 +353,4 @@ export const githubRouter = createTRPCRouter({ }); } }), - - getOrganizationsWithOAuth: protectedProcedure.query(async () => { - try { - const { octokit } = await getUserGitHubOAuth(); - - const { data } = await octokit.rest.orgs.listForAuthenticatedUser({ - per_page: 100, - }); - - return data; - } catch (error) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: - 'GitHub OAuth access is invalid or has been revoked. Please reconnect your GitHub account.', - cause: error, - }); - } - }), });