feat: new improvement for the GitHub API Authentication Fix (#1537)

* Add environment variables section to ConnectionsTab and fallback token to git-info

* Add remaining code from original branch

* Import Repo Fix

* refactor the UI

* add a rate limit counter

* Update GithubConnection.tsx

* Update NetlifyConnection.tsx

* fix: ui style

* Sync with upstream and preserve GitHub connection and DataTab fixes

* fix disconnect buttons

* revert commits

* Update api.git-proxy.$.ts

* Update api.git-proxy.$.ts
This commit is contained in:
Stijnus
2025-03-29 21:12:47 +01:00
committed by GitHub
parent 1c561a0615
commit 0487ed1438
23 changed files with 3321 additions and 778 deletions

View File

@@ -0,0 +1,377 @@
import React, { useState } from 'react';
import { toast } from 'react-toastify';
import { Button } from '~/components/ui/Button';
import { Badge } from '~/components/ui/Badge';
import { classNames } from '~/utils/classNames';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
import { CodeBracketIcon, ChevronDownIcon } from '@heroicons/react/24/outline';
/**
* A diagnostics component to help troubleshoot connection issues
*/
export default function ConnectionDiagnostics() {
const [diagnosticResults, setDiagnosticResults] = useState<any>(null);
const [isRunning, setIsRunning] = useState(false);
const [showDetails, setShowDetails] = useState(false);
// Run diagnostics when requested
const runDiagnostics = async () => {
try {
setIsRunning(true);
setDiagnosticResults(null);
// Check browser-side storage
const localStorageChecks = {
githubConnection: localStorage.getItem('github_connection'),
netlifyConnection: localStorage.getItem('netlify_connection'),
};
// Get diagnostic data from server
const response = await fetch('/api/system/diagnostics');
if (!response.ok) {
throw new Error(`Diagnostics API error: ${response.status}`);
}
const serverDiagnostics = await response.json();
// Get GitHub token if available
const githubToken = localStorageChecks.githubConnection
? JSON.parse(localStorageChecks.githubConnection)?.token
: null;
const authHeaders = {
...(githubToken ? { Authorization: `Bearer ${githubToken}` } : {}),
'Content-Type': 'application/json',
};
console.log('Testing GitHub endpoints with token:', githubToken ? 'present' : 'missing');
// Test GitHub API endpoints
const githubEndpoints = [
{ name: 'User', url: '/api/system/git-info?action=getUser' },
{ name: 'Repos', url: '/api/system/git-info?action=getRepos' },
{ name: 'Default', url: '/api/system/git-info' },
];
const githubResults = await Promise.all(
githubEndpoints.map(async (endpoint) => {
try {
const resp = await fetch(endpoint.url, {
headers: authHeaders,
});
return {
endpoint: endpoint.name,
status: resp.status,
ok: resp.ok,
};
} catch (error) {
return {
endpoint: endpoint.name,
error: error instanceof Error ? error.message : String(error),
ok: false,
};
}
}),
);
// Check if Netlify token works
let netlifyUserCheck = null;
const netlifyToken = localStorageChecks.netlifyConnection
? JSON.parse(localStorageChecks.netlifyConnection || '{"token":""}').token
: '';
if (netlifyToken) {
try {
const netlifyResp = await fetch('https://api.netlify.com/api/v1/user', {
headers: {
Authorization: `Bearer ${netlifyToken}`,
},
});
netlifyUserCheck = {
status: netlifyResp.status,
ok: netlifyResp.ok,
};
} catch (error) {
netlifyUserCheck = {
error: error instanceof Error ? error.message : String(error),
ok: false,
};
}
}
// Compile results
const results = {
timestamp: new Date().toISOString(),
localStorage: {
hasGithubConnection: Boolean(localStorageChecks.githubConnection),
hasNetlifyConnection: Boolean(localStorageChecks.netlifyConnection),
githubConnectionParsed: localStorageChecks.githubConnection
? JSON.parse(localStorageChecks.githubConnection)
: null,
netlifyConnectionParsed: localStorageChecks.netlifyConnection
? JSON.parse(localStorageChecks.netlifyConnection)
: null,
},
apiEndpoints: {
github: githubResults,
netlify: netlifyUserCheck,
},
serverDiagnostics,
};
setDiagnosticResults(results);
// Display simple results
if (results.localStorage.hasGithubConnection && results.apiEndpoints.github.some((r: { ok: boolean }) => !r.ok)) {
toast.error('GitHub API connections are failing. Try reconnecting.');
}
if (results.localStorage.hasNetlifyConnection && netlifyUserCheck && !netlifyUserCheck.ok) {
toast.error('Netlify API connection is failing. Try reconnecting.');
}
if (!results.localStorage.hasGithubConnection && !results.localStorage.hasNetlifyConnection) {
toast.info('No connection data found in browser storage.');
}
} catch (error) {
console.error('Diagnostics error:', error);
toast.error('Error running diagnostics');
setDiagnosticResults({ error: error instanceof Error ? error.message : String(error) });
} finally {
setIsRunning(false);
}
};
// Helper to reset GitHub connection
const resetGitHubConnection = () => {
try {
localStorage.removeItem('github_connection');
document.cookie = 'githubToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
document.cookie = 'githubUsername=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
document.cookie = 'git:github.com=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
toast.success('GitHub connection data cleared. Please refresh the page and reconnect.');
} catch (error) {
console.error('Error clearing GitHub data:', error);
toast.error('Failed to clear GitHub connection data');
}
};
// Helper to reset Netlify connection
const resetNetlifyConnection = () => {
try {
localStorage.removeItem('netlify_connection');
document.cookie = 'netlifyToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
toast.success('Netlify connection data cleared. Please refresh the page and reconnect.');
} catch (error) {
console.error('Error clearing Netlify data:', error);
toast.error('Failed to clear Netlify connection data');
}
};
return (
<div className="flex flex-col gap-6">
{/* Connection Status Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* GitHub Connection Card */}
<div className="p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200 h-[180px] flex flex-col">
<div className="flex items-center gap-2">
<div className="i-ph:github-logo text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent w-4 h-4" />
<div className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
GitHub Connection
</div>
</div>
{diagnosticResults ? (
<>
<div className="flex items-center gap-2 mt-2">
<span
className={classNames(
'text-xl font-semibold',
diagnosticResults.localStorage.hasGithubConnection
? 'text-green-500 dark:text-green-400'
: 'text-red-500 dark:text-red-400',
)}
>
{diagnosticResults.localStorage.hasGithubConnection ? 'Connected' : 'Not Connected'}
</span>
</div>
{diagnosticResults.localStorage.hasGithubConnection && (
<>
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
<div className="i-ph:user w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
User: {diagnosticResults.localStorage.githubConnectionParsed?.user?.login || 'N/A'}
</div>
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
<div className="i-ph:check-circle w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
API Status:{' '}
<Badge
variant={
diagnosticResults.apiEndpoints.github.every((r: { ok: boolean }) => r.ok)
? 'default'
: 'destructive'
}
className="ml-1"
>
{diagnosticResults.apiEndpoints.github.every((r: { ok: boolean }) => r.ok) ? 'OK' : 'Failed'}
</Badge>
</div>
</>
)}
{!diagnosticResults.localStorage.hasGithubConnection && (
<Button
onClick={() => window.location.reload()}
variant="outline"
size="sm"
className="mt-auto self-start hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
>
<div className="i-ph:plug w-3.5 h-3.5 mr-1" />
Connect Now
</Button>
)}
</>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-2">
<div className="i-ph:info w-4 h-4" />
Run diagnostics to check connection status
</div>
</div>
)}
</div>
{/* Netlify Connection Card */}
<div className="p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200 h-[180px] flex flex-col">
<div className="flex items-center gap-2">
<div className="i-si:netlify text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent w-4 h-4" />
<div className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Netlify Connection
</div>
</div>
{diagnosticResults ? (
<>
<div className="flex items-center gap-2 mt-2">
<span
className={classNames(
'text-xl font-semibold',
diagnosticResults.localStorage.hasNetlifyConnection
? 'text-green-500 dark:text-green-400'
: 'text-red-500 dark:text-red-400',
)}
>
{diagnosticResults.localStorage.hasNetlifyConnection ? 'Connected' : 'Not Connected'}
</span>
</div>
{diagnosticResults.localStorage.hasNetlifyConnection && (
<>
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
<div className="i-ph:user w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
User:{' '}
{diagnosticResults.localStorage.netlifyConnectionParsed?.user?.full_name ||
diagnosticResults.localStorage.netlifyConnectionParsed?.user?.email ||
'N/A'}
</div>
<div className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2 flex items-center gap-1.5">
<div className="i-ph:check-circle w-3.5 h-3.5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
API Status:{' '}
<Badge
variant={diagnosticResults.apiEndpoints.netlify?.ok ? 'default' : 'destructive'}
className="ml-1"
>
{diagnosticResults.apiEndpoints.netlify?.ok ? 'OK' : 'Failed'}
</Badge>
</div>
</>
)}
{!diagnosticResults.localStorage.hasNetlifyConnection && (
<Button
onClick={() => window.location.reload()}
variant="outline"
size="sm"
className="mt-auto self-start hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
>
<div className="i-ph:plug w-3.5 h-3.5 mr-1" />
Connect Now
</Button>
)}
</>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-2">
<div className="i-ph:info w-4 h-4" />
Run diagnostics to check connection status
</div>
</div>
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap gap-4">
<Button
onClick={runDiagnostics}
disabled={isRunning}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
>
{isRunning ? (
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
) : (
<div className="i-ph:activity w-4 h-4" />
)}
{isRunning ? 'Running Diagnostics...' : 'Run Diagnostics'}
</Button>
<Button
onClick={resetGitHubConnection}
disabled={isRunning}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
>
<div className="i-ph:github-logo w-4 h-4" />
Reset GitHub Connection
</Button>
<Button
onClick={resetNetlifyConnection}
disabled={isRunning}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
>
<div className="i-si:netlify w-4 h-4" />
Reset Netlify Connection
</Button>
</div>
{/* Details Panel */}
{diagnosticResults && (
<div className="mt-4">
<Collapsible open={showDetails} onOpenChange={setShowDetails} className="w-full">
<CollapsibleTrigger className="w-full">
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200">
<div className="flex items-center gap-2">
<CodeBracketIcon className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Diagnostic Details
</span>
</div>
<ChevronDownIcon
className={classNames(
'w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary',
showDetails ? 'rotate-180' : '',
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="p-4 mt-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor">
<pre className="text-xs overflow-auto max-h-96 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
{JSON.stringify(diagnosticResults, null, 2)}
</pre>
</div>
</CollapsibleContent>
</Collapsible>
</div>
)}
</div>
);
}

View File

@@ -1,45 +1,180 @@
import { motion } from 'framer-motion';
import React, { Suspense } from 'react';
import React, { Suspense, useState } from 'react';
import { classNames } from '~/utils/classNames';
import ConnectionDiagnostics from './ConnectionDiagnostics';
import { Button } from '~/components/ui/Button';
// Use React.lazy for dynamic imports
const GithubConnection = React.lazy(() => import('./GithubConnection'));
const GitHubConnection = React.lazy(() => import('./GithubConnection'));
const NetlifyConnection = React.lazy(() => import('./NetlifyConnection'));
// Loading fallback component
const LoadingFallback = () => (
<div className="p-4 bg-white dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]">
<div className="flex items-center gap-2 text-bolt-elements-textSecondary">
<div className="i-ph:spinner-gap w-5 h-5 animate-spin" />
<div className="p-4 bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1 rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor">
<div className="flex items-center justify-center gap-2 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
<span>Loading connection...</span>
</div>
</div>
);
export default function ConnectionsTab() {
const [isEnvVarsExpanded, setIsEnvVarsExpanded] = useState(false);
const [showDiagnostics, setShowDiagnostics] = useState(false);
return (
<div className="space-y-4">
<div className="space-y-6">
{/* Header */}
<motion.div
className="flex items-center gap-2 mb-2"
className="flex items-center justify-between gap-2"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div className="i-ph:plugs-connected w-5 h-5 text-purple-500" />
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">Connection Settings</h2>
<div className="flex items-center gap-2">
<div className="i-ph:plugs-connected w-5 h-5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<h2 className="text-lg font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Connection Settings
</h2>
</div>
<Button
onClick={() => setShowDiagnostics(!showDiagnostics)}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary transition-colors"
>
{showDiagnostics ? (
<>
<div className="i-ph:eye-slash w-4 h-4" />
Hide Diagnostics
</>
) : (
<>
<div className="i-ph:wrench w-4 h-4" />
Troubleshoot Connections
</>
)}
</Button>
</motion.div>
<p className="text-sm text-bolt-elements-textSecondary mb-6">
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
Manage your external service connections and integrations
</p>
<div className="grid grid-cols-1 gap-4">
{/* Diagnostics Tool - Conditionally rendered */}
{showDiagnostics && <ConnectionDiagnostics />}
{/* Environment Variables Info - Collapsible */}
<motion.div
className="bg-bolt-elements-background dark:bg-bolt-elements-background rounded-lg border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<div className="p-6">
<button
onClick={() => setIsEnvVarsExpanded(!isEnvVarsExpanded)}
className={classNames(
'w-full bg-transparent flex items-center justify-between',
'hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary',
'dark:hover:bg-bolt-elements-item-backgroundActive/10 dark:hover:text-bolt-elements-textPrimary',
'rounded-md p-2 -m-2 transition-colors',
)}
>
<div className="flex items-center gap-2">
<div className="i-ph:info w-5 h-5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<h3 className="text-base font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Environment Variables
</h3>
</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary transition-transform',
isEnvVarsExpanded ? 'rotate-180' : '',
)}
/>
</button>
{isEnvVarsExpanded && (
<div className="mt-4">
<p className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
You can configure connections using environment variables in your{' '}
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
.env.local
</code>{' '}
file:
</p>
<div className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 p-3 rounded-md text-xs font-mono overflow-x-auto">
<div className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
# GitHub Authentication
</div>
<div className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
VITE_GITHUB_ACCESS_TOKEN=your_token_here
</div>
<div className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
# Optional: Specify token type (defaults to 'classic' if not specified)
</div>
<div className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
VITE_GITHUB_TOKEN_TYPE=classic|fine-grained
</div>
<div className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mt-2">
# Netlify Authentication
</div>
<div className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
VITE_NETLIFY_ACCESS_TOKEN=your_token_here
</div>
</div>
<div className="mt-3 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary space-y-1">
<p>
<span className="font-medium">Token types:</span>
</p>
<ul className="list-disc list-inside pl-2 space-y-1">
<li>
<span className="font-medium">classic</span> - Personal Access Token with{' '}
<code className="px-1 py-0.5 bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 rounded">
repo, read:org, read:user
</code>{' '}
scopes
</li>
<li>
<span className="font-medium">fine-grained</span> - Fine-grained token with Repository and
Organization access
</li>
</ul>
<p className="mt-2">
When set, these variables will be used automatically without requiring manual connection.
</p>
</div>
</div>
)}
</div>
</motion.div>
<div className="grid grid-cols-1 gap-6">
<Suspense fallback={<LoadingFallback />}>
<GithubConnection />
<GitHubConnection />
</Suspense>
<Suspense fallback={<LoadingFallback />}>
<NetlifyConnection />
</Suspense>
</div>
{/* Additional help text */}
<div className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-2 p-4 rounded-lg">
<p className="flex items-center gap-1 mb-2">
<span className="i-ph:lightbulb w-4 h-4 text-bolt-elements-icon-success dark:text-bolt-elements-icon-success" />
<span className="font-medium">Troubleshooting Tip:</span>
</p>
<p className="mb-2">
If you're having trouble with connections, try using the troubleshooting tool at the top of this page. It can
help diagnose and fix common connection issues.
</p>
<p>For persistent issues:</p>
<ol className="list-decimal list-inside pl-4 mt-1">
<li>Check your browser console for errors</li>
<li>Verify that your tokens have the correct permissions</li>
<li>Try clearing your browser cache and cookies</li>
<li>Ensure your browser allows third-party cookies if using integrations</li>
</ol>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,263 +1,755 @@
import React, { useEffect, useState } from 'react';
import { motion } from 'framer-motion';
import React, { useState, useEffect } from 'react';
import { toast } from 'react-toastify';
import { useStore } from '@nanostores/react';
import { logStore } from '~/lib/stores/logs';
import { classNames } from '~/utils/classNames';
import { useStore } from '@nanostores/react';
import { netlifyConnection, updateNetlifyConnection, initializeNetlifyConnection } from '~/lib/stores/netlify';
import type { NetlifySite, NetlifyDeploy, NetlifyBuild, NetlifyUser } from '~/types/netlify';
import {
netlifyConnection,
isConnecting,
isFetchingStats,
updateNetlifyConnection,
fetchNetlifyStats,
} from '~/lib/stores/netlify';
import type { NetlifyUser } from '~/types/netlify';
CloudIcon,
BuildingLibraryIcon,
ClockIcon,
CodeBracketIcon,
CheckCircleIcon,
XCircleIcon,
TrashIcon,
ArrowPathIcon,
LockClosedIcon,
LockOpenIcon,
RocketLaunchIcon,
} from '@heroicons/react/24/outline';
import { Button } from '~/components/ui/Button';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '~/components/ui/Collapsible';
import { formatDistanceToNow } from 'date-fns';
import { Badge } from '~/components/ui/Badge';
// Add the Netlify logo SVG component at the top of the file
const NetlifyLogo = () => (
<svg viewBox="0 0 40 40" className="w-5 h-5">
<path
fill="currentColor"
d="M28.589 14.135l-.014-.006c-.008-.003-.016-.006-.023-.013a.11.11 0 0 1-.028-.093l.773-4.726 3.625 3.626-3.77 1.604a.083.083 0 0 1-.033.006h-.015c-.005-.003-.01-.007-.02-.017a1.716 1.716 0 0 0-.495-.381zm5.258-.288l3.876 3.876c.805.806 1.208 1.208 1.674 1.355a2 2 0 0 1 1.206 0c.466-.148.869-.55 1.674-1.356L8.73 28.73l2.349-3.643c.011-.018.022-.034.04-.047.025-.018.061-.01.091 0a2.434 2.434 0 0 0 1.638-.083c.027-.01.054-.017.075.002a.19.19 0 0 1 .028.032L21.95 38.05zM7.863 27.863L5.8 25.8l4.074-1.738a.084.084 0 0 1 .033-.007c.034 0 .054.034.072.065a2.91 2.91 0 0 0 .13.184l.013.016c.012.017.004.034-.008.05l-2.25 3.493zm-2.976-2.976l-2.61-2.61c-.444-.444-.766-.766-.99-1.043l7.936 1.646a.84.84 0 0 0 .03.005c.049.008.103.017.103.063 0 .05-.059.073-.109.092l-.023.01-4.337 1.837zM.831 19.892a2 2 0 0 1 .09-.495c.148-.466.55-.868 1.356-1.674l3.34-3.34a2175.525 2175.525 0 0 0 4.626 6.687c.027.036.057.076.026.106-.146.161-.292.337-.395.528a.16.16 0 0 1-.05.062c-.013.008-.027.005-.042.002H9.78L.831 19.892zm5.68-6.403l4.491-4.491c.422.185 1.958.834 3.332 1.414 1.04.44 1.988.84 2.286.97.03.012.057.024.07.054.008.018.004.041 0 .06a2.003 2.003 0 0 0 .523 1.828c.03.03 0 .073-.026.11l-.014.021-4.56 7.063c-.012.02-.023.037-.043.05-.024.015-.058.008-.086.001a2.274 2.274 0 0 0-.543-.074c-.164 0-.342.03-.522.063h-.001c-.02.003-.038.007-.054-.005a.21.21 0 0 1-.045-.051l-4.808-7.013zm5.398-5.398l5.814-5.814c.805-.805 1.208-1.208 1.674-1.355a2 2 0 0 1 1.206 0c.466.147.869.55 1.674 1.355l1.26 1.26-4.135 6.404a.155.155 0 0 1-.041.048c-.025.017-.06.01-.09 0a2.097 2.097 0 0 0-1.92.37c-.027.028-.067.012-.101-.003-.54-.235-4.74-2.01-5.341-2.265zm12.506-3.676l3.818 3.818-.92 5.698v.015a.135.135 0 0 1-.008.038c-.01.02-.03.024-.05.03a1.83 1.83 0 0 0-.548.273.154.154 0 0 0-.02.017c-.011.012-.022.023-.04.025a.114.114 0 0 1-.043-.007l-5.818-2.472-.011-.005c-.037-.015-.081-.033-.081-.071a2.198 2.198 0 0 0-.31-.915c-.028-.046-.059-.094-.035-.141l4.066-6.303zm-3.932 8.606l5.454 2.31c.03.014.063.027.076.058a.106.106 0 0 1 0 .057c-.016.08-.03.171-.03.263v.153c0 .038-.039.054-.075.069l-.011.004c-.864.369-12.13 5.173-12.147 5.173-.017 0-.035 0-.052-.017-.03-.03 0-.072.027-.11a.76.76 0 0 0 .014-.02l4.482-6.94.008-.012c.026-.042.056-.089.104-.089l.045.007c.102.014.192.027.283.027.68 0 1.31-.331 1.69-.897a.16.16 0 0 1 .034-.04c.027-.02.067-.01.098.004zm-6.246 9.185l12.28-5.237s.018 0 .035.017c.067.067.124.112.179.154l.027.017c.025.014.05.03.052.056 0 .01 0 .016-.002.025L25.756 23.7l-.004.026c-.007.05-.014.107-.061.107a1.729 1.729 0 0 0-1.373.847l-.005.008c-.014.023-.027.045-.05.057-.021.01-.048.006-.07.001l-9.793-2.02c-.01-.002-.152-.519-.163-.52z"
/>
</svg>
);
// Add new interface for site actions
interface SiteAction {
name: string;
icon: React.ComponentType<any>;
action: (siteId: string) => Promise<void>;
requiresConfirmation?: boolean;
variant?: 'default' | 'destructive' | 'outline';
}
export default function NetlifyConnection() {
const connection = useStore(netlifyConnection);
const connecting = useStore(isConnecting);
const fetchingStats = useStore(isFetchingStats);
const [isSitesExpanded, setIsSitesExpanded] = useState(false);
const [tokenInput, setTokenInput] = useState('');
const [fetchingStats, setFetchingStats] = useState(false);
const [sites, setSites] = useState<NetlifySite[]>([]);
const [deploys, setDeploys] = useState<NetlifyDeploy[]>([]);
const [builds, setBuilds] = useState<NetlifyBuild[]>([]);
const [deploymentCount, setDeploymentCount] = useState(0);
const [lastUpdated, setLastUpdated] = useState('');
const [isStatsOpen, setIsStatsOpen] = useState(false);
const [activeSiteIndex, setActiveSiteIndex] = useState(0);
const [isActionLoading, setIsActionLoading] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
useEffect(() => {
const fetchSites = async () => {
if (connection.user && connection.token) {
await fetchNetlifyStats(connection.token);
}
};
fetchSites();
}, [connection.user, connection.token]);
// Add site actions
const siteActions: SiteAction[] = [
{
name: 'Clear Cache',
icon: ArrowPathIcon,
action: async (siteId: string) => {
try {
const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}/cache`, {
method: 'POST',
headers: {
Authorization: `Bearer ${connection.token}`,
},
});
const handleConnect = async (event: React.FormEvent) => {
event.preventDefault();
isConnecting.set(true);
if (!response.ok) {
throw new Error('Failed to clear cache');
}
toast.success('Site cache cleared successfully');
} catch (err: unknown) {
const error = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to clear site cache: ${error}`);
}
},
},
{
name: 'Delete Site',
icon: TrashIcon,
action: async (siteId: string) => {
try {
const response = await fetch(`https://api.netlify.com/api/v1/sites/${siteId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${connection.token}`,
},
});
if (!response.ok) {
throw new Error('Failed to delete site');
}
toast.success('Site deleted successfully');
fetchNetlifyStats(connection.token);
} catch (err: unknown) {
const error = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to delete site: ${error}`);
}
},
requiresConfirmation: true,
variant: 'destructive',
},
];
// Add deploy management functions
const handleDeploy = async (siteId: string, deployId: string, action: 'lock' | 'unlock' | 'publish') => {
try {
const response = await fetch('https://api.netlify.com/api/v1/user', {
setIsActionLoading(true);
const endpoint =
action === 'publish'
? `https://api.netlify.com/api/v1/sites/${siteId}/deploys/${deployId}/restore`
: `https://api.netlify.com/api/v1/deploys/${deployId}/${action}`;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: `Bearer ${connection.token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Invalid token or unauthorized');
throw new Error(`Failed to ${action} deploy`);
}
toast.success(`Deploy ${action}ed successfully`);
fetchNetlifyStats(connection.token);
} catch (err: unknown) {
const error = err instanceof Error ? err.message : 'Unknown error';
toast.error(`Failed to ${action} deploy: ${error}`);
} finally {
setIsActionLoading(false);
}
};
useEffect(() => {
// Initialize connection with environment token if available
initializeNetlifyConnection();
}, []);
useEffect(() => {
// Check if we have a connection with a token but no stats
if (connection.user && connection.token && (!connection.stats || !connection.stats.sites)) {
fetchNetlifyStats(connection.token);
}
// Update local state from connection
if (connection.stats) {
setSites(connection.stats.sites || []);
setDeploys(connection.stats.deploys || []);
setBuilds(connection.stats.builds || []);
setDeploymentCount(connection.stats.deploys?.length || 0);
setLastUpdated(connection.stats.lastDeployTime || '');
}
}, [connection]);
const handleConnect = async () => {
if (!tokenInput) {
toast.error('Please enter a Netlify API token');
return;
}
setIsConnecting(true);
try {
const response = await fetch('https://api.netlify.com/api/v1/user', {
headers: {
Authorization: `Bearer ${tokenInput}`,
},
});
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const userData = (await response.json()) as NetlifyUser;
// Update the connection store
updateNetlifyConnection({
user: userData,
token: connection.token,
token: tokenInput,
});
await fetchNetlifyStats(connection.token);
toast.success('Successfully connected to Netlify');
toast.success('Connected to Netlify successfully');
// Fetch stats after successful connection
fetchNetlifyStats(tokenInput);
} catch (error) {
console.error('Auth error:', error);
logStore.logError('Failed to authenticate with Netlify', { error });
toast.error('Failed to connect to Netlify');
updateNetlifyConnection({ user: null, token: '' });
console.error('Error connecting to Netlify:', error);
toast.error(`Failed to connect to Netlify: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
isConnecting.set(false);
setIsConnecting(false);
setTokenInput('');
}
};
const handleDisconnect = () => {
// Clear from localStorage
localStorage.removeItem('netlify_connection');
// Remove cookies
document.cookie = 'netlifyToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
// Update the store
updateNetlifyConnection({ user: null, token: '' });
toast.success('Disconnected from Netlify');
};
const fetchNetlifyStats = async (token: string) => {
setFetchingStats(true);
try {
// Fetch sites
const sitesResponse = await fetch('https://api.netlify.com/api/v1/sites', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!sitesResponse.ok) {
throw new Error(`Failed to fetch sites: ${sitesResponse.statusText}`);
}
const sitesData = (await sitesResponse.json()) as NetlifySite[];
setSites(sitesData);
// Fetch recent deploys for the first site (if any)
let deploysData: NetlifyDeploy[] = [];
let buildsData: NetlifyBuild[] = [];
let lastDeployTime = '';
if (sitesData && sitesData.length > 0) {
const firstSite = sitesData[0];
// Fetch deploys
const deploysResponse = await fetch(`https://api.netlify.com/api/v1/sites/${firstSite.id}/deploys`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (deploysResponse.ok) {
deploysData = (await deploysResponse.json()) as NetlifyDeploy[];
setDeploys(deploysData);
setDeploymentCount(deploysData.length);
// Get the latest deploy time
if (deploysData.length > 0) {
lastDeployTime = deploysData[0].created_at;
setLastUpdated(lastDeployTime);
// Fetch builds for the site
const buildsResponse = await fetch(`https://api.netlify.com/api/v1/sites/${firstSite.id}/builds`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (buildsResponse.ok) {
buildsData = (await buildsResponse.json()) as NetlifyBuild[];
setBuilds(buildsData);
}
}
}
}
// Update the stats in the store
updateNetlifyConnection({
stats: {
sites: sitesData,
deploys: deploysData,
builds: buildsData,
lastDeployTime,
totalSites: sitesData.length,
},
});
toast.success('Netlify stats updated');
} catch (error) {
console.error('Error fetching Netlify stats:', error);
toast.error(`Failed to fetch Netlify stats: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setFetchingStats(false);
}
};
const renderStats = () => {
if (!connection.user || !connection.stats) {
return null;
}
return (
<div className="mt-6">
<Collapsible open={isStatsOpen} onOpenChange={setIsStatsOpen}>
<CollapsibleTrigger asChild>
<div className="flex items-center justify-between p-4 rounded-lg bg-bolt-elements-background dark:bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 dark:hover:border-bolt-elements-borderColorActive/70 transition-all duration-200">
<div className="flex items-center gap-2">
<div className="i-ph:chart-bar w-4 h-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<span className="text-sm font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Netlify Stats
</span>
</div>
<div
className={classNames(
'i-ph:caret-down w-4 h-4 transform transition-transform duration-200 text-bolt-elements-textSecondary',
isStatsOpen ? 'rotate-180' : '',
)}
/>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="space-y-4 mt-4">
<div className="flex flex-wrap items-center gap-4">
<Badge
variant="outline"
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent" />
<span>{connection.stats.totalSites} Sites</span>
</Badge>
<Badge
variant="outline"
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<RocketLaunchIcon className="h-4 w-4 text-bolt-elements-item-contentAccent" />
<span>{deploymentCount} Deployments</span>
</Badge>
{lastUpdated && (
<Badge
variant="outline"
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<ClockIcon className="h-4 w-4 text-bolt-elements-item-contentAccent" />
<span>Updated {formatDistanceToNow(new Date(lastUpdated))} ago</span>
</Badge>
)}
</div>
{sites.length > 0 && (
<div className="mt-4 space-y-4">
<div className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<h4 className="text-sm font-medium flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
Your Sites
</h4>
<Button
variant="outline"
size="sm"
onClick={() => fetchNetlifyStats(connection.token)}
disabled={fetchingStats}
className="flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary hover:bg-bolt-elements-item-backgroundActive/10"
>
<ArrowPathIcon
className={classNames(
'h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent',
{ 'animate-spin': fetchingStats },
)}
/>
{fetchingStats ? 'Refreshing...' : 'Refresh'}
</Button>
</div>
<div className="space-y-3">
{sites.map((site, index) => (
<div
key={site.id}
className={classNames(
'bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border rounded-lg p-4 transition-all',
activeSiteIndex === index
? 'border-bolt-elements-item-contentAccent bg-bolt-elements-item-backgroundActive/10'
: 'border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70',
)}
onClick={() => {
setActiveSiteIndex(index);
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CloudIcon className="h-5 w-5 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<span className="font-medium text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
{site.name}
</span>
</div>
<div className="flex items-center gap-2">
<Badge
variant={site.published_deploy?.state === 'ready' ? 'default' : 'destructive'}
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
{site.published_deploy?.state === 'ready' ? (
<CheckCircleIcon className="h-4 w-4 text-green-500" />
) : (
<XCircleIcon className="h-4 w-4 text-red-500" />
)}
<span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
{site.published_deploy?.state || 'Unknown'}
</span>
</Badge>
</div>
</div>
<div className="mt-3 flex items-center gap-2">
<a
href={site.ssl_url || site.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm flex items-center gap-1 transition-colors text-bolt-elements-link-text hover:text-bolt-elements-link-textHover dark:text-white dark:hover:text-bolt-elements-link-textHover"
onClick={(e) => e.stopPropagation()}
>
<CloudIcon className="h-3 w-3 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<span className="underline decoration-1 underline-offset-2">
{site.ssl_url || site.url}
</span>
</a>
</div>
{activeSiteIndex === index && (
<>
<div className="mt-4 pt-3 border-t border-bolt-elements-borderColor">
<div className="flex items-center gap-2">
{siteActions.map((action) => (
<Button
key={action.name}
variant={action.variant || 'outline'}
size="sm"
onClick={async (e) => {
e.stopPropagation();
if (action.requiresConfirmation) {
if (!confirm(`Are you sure you want to ${action.name.toLowerCase()}?`)) {
return;
}
}
setIsActionLoading(true);
await action.action(site.id);
setIsActionLoading(false);
}}
disabled={isActionLoading}
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<action.icon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
{action.name}
</Button>
))}
</div>
</div>
{site.published_deploy && (
<div className="mt-3 text-sm">
<div className="flex items-center gap-1">
<ClockIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<span className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
Published {formatDistanceToNow(new Date(site.published_deploy.published_at))} ago
</span>
</div>
{site.published_deploy.branch && (
<div className="flex items-center gap-1 mt-1">
<CodeBracketIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<span className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
Branch: {site.published_deploy.branch}
</span>
</div>
)}
</div>
)}
</>
)}
</div>
))}
</div>
</div>
{activeSiteIndex !== -1 && deploys.length > 0 && (
<div className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
Recent Deployments
</h4>
</div>
<div className="space-y-2">
{deploys.map((deploy) => (
<div
key={deploy.id}
className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-3"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge
variant={
deploy.state === 'ready'
? 'default'
: deploy.state === 'error'
? 'destructive'
: 'outline'
}
className="flex items-center gap-1"
>
{deploy.state === 'ready' ? (
<CheckCircleIcon className="h-4 w-4 text-green-500" />
) : deploy.state === 'error' ? (
<XCircleIcon className="h-4 w-4 text-red-500" />
) : (
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent" />
)}
<span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
{deploy.state}
</span>
</Badge>
</div>
<span className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
{formatDistanceToNow(new Date(deploy.created_at))} ago
</span>
</div>
{deploy.branch && (
<div className="mt-2 text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary flex items-center gap-1">
<CodeBracketIcon className="h-3 w-3 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<span className="text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
Branch: {deploy.branch}
</span>
</div>
)}
{deploy.deploy_url && (
<div className="mt-2 text-xs">
<a
href={deploy.deploy_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 transition-colors text-bolt-elements-link-text hover:text-bolt-elements-link-textHover dark:text-white dark:hover:text-bolt-elements-link-textHover"
onClick={(e) => e.stopPropagation()}
>
<CloudIcon className="h-3 w-3 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
<span className="underline decoration-1 underline-offset-2">{deploy.deploy_url}</span>
</a>
</div>
)}
<div className="flex items-center gap-2 mt-2">
<Button
variant="outline"
size="sm"
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'publish')}
disabled={isActionLoading}
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<BuildingLibraryIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
Publish
</Button>
{deploy.state === 'ready' ? (
<Button
variant="outline"
size="sm"
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'lock')}
disabled={isActionLoading}
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<LockClosedIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
Lock
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => handleDeploy(sites[activeSiteIndex].id, deploy.id, 'unlock')}
disabled={isActionLoading}
className="flex items-center gap-1 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary"
>
<LockOpenIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
Unlock
</Button>
)}
</div>
</div>
))}
</div>
</div>
)}
{activeSiteIndex !== -1 && builds.length > 0 && (
<div className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium flex items-center gap-2 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
<CodeBracketIcon className="h-4 w-4 text-bolt-elements-item-contentAccent dark:text-bolt-elements-item-contentAccent" />
Recent Builds
</h4>
</div>
<div className="space-y-2">
{builds.map((build) => (
<div
key={build.id}
className="bg-bolt-elements-background dark:bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg p-3"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge
variant={
build.done && !build.error ? 'default' : build.error ? 'destructive' : 'outline'
}
className="flex items-center gap-1"
>
{build.done && !build.error ? (
<CheckCircleIcon className="h-4 w-4" />
) : build.error ? (
<XCircleIcon className="h-4 w-4" />
) : (
<CodeBracketIcon className="h-4 w-4" />
)}
<span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
{build.done ? (build.error ? 'Failed' : 'Completed') : 'In Progress'}
</span>
</Badge>
</div>
<span className="text-xs text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
{formatDistanceToNow(new Date(build.created_at))} ago
</span>
</div>
{build.error && (
<div className="mt-2 text-xs text-bolt-elements-textDestructive dark:text-bolt-elements-textDestructive flex items-center gap-1">
<XCircleIcon className="h-3 w-3 text-bolt-elements-textDestructive dark:text-bolt-elements-textDestructive" />
Error: {build.error}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
</div>
);
};
return (
<motion.div
className="bg-[#FFFFFF] dark:bg-[#0A0A0A] rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A]"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<div className="p-6 space-y-6">
<div className="space-y-6 bg-bolt-elements-background dark:bg-bolt-elements-background border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor rounded-lg">
<div className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<img
className="w-5 h-5"
height="24"
width="24"
crossOrigin="anonymous"
src="https://cdn.simpleicons.org/netlify"
/>
<h3 className="text-base font-medium text-bolt-elements-textPrimary">Netlify Connection</h3>
<div className="text-[#00AD9F]">
<NetlifyLogo />
</div>
<h2 className="text-lg font-medium text-bolt-elements-textPrimary">Netlify Connection</h2>
</div>
</div>
{!connection.user ? (
<div className="space-y-4">
<div>
<label className="block text-sm text-bolt-elements-textSecondary mb-2">Personal Access Token</label>
<input
type="password"
value={connection.token}
onChange={(e) => updateNetlifyConnection({ ...connection, token: e.target.value })}
disabled={connecting}
placeholder="Enter your Netlify personal access token"
className={classNames(
'w-full px-3 py-2 rounded-lg text-sm',
'bg-[#F8F8F8] dark:bg-[#1A1A1A]',
'border border-[#E5E5E5] dark:border-[#333333]',
'text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-[#00AD9F]',
'disabled:opacity-50',
)}
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary">
<a
href="https://app.netlify.com/user/applications#personal-access-tokens"
target="_blank"
rel="noopener noreferrer"
className="text-[#00AD9F] hover:underline inline-flex items-center gap-1"
>
Get your token
<div className="i-ph:arrow-square-out w-4 h-4" />
</a>
</div>
</div>
<button
onClick={handleConnect}
disabled={connecting || !connection.token}
<div className="mt-4">
<label className="block text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary mb-2">
API Token
</label>
<input
type="password"
value={tokenInput}
onChange={(e) => setTokenInput(e.target.value)}
placeholder="Enter your Netlify API token"
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-[#00AD9F] text-white',
'hover:bg-[#00968A]',
'disabled:opacity-50 disabled:cursor-not-allowed',
'w-full px-3 py-2 rounded-lg text-sm',
'bg-bolt-elements-background-depth-1 dark:bg-bolt-elements-background-depth-1',
'border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor',
'text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary dark:placeholder-bolt-elements-textTertiary',
'focus:outline-none focus:ring-1 focus:ring-bolt-elements-item-contentAccent dark:focus:ring-bolt-elements-item-contentAccent',
)}
>
{connecting ? (
<>
<div className="i-ph:spinner-gap animate-spin" />
Connecting...
</>
) : (
<>
<div className="i-ph:plug-charging w-4 h-4" />
Connect
</>
)}
</button>
/>
<div className="mt-2 text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary">
<a
href="https://app.netlify.com/user/applications#personal-access-tokens"
target="_blank"
rel="noopener noreferrer"
className="text-bolt-elements-link-text dark:text-bolt-elements-link-text hover:text-bolt-elements-link-textHover dark:hover:text-bolt-elements-link-textHover flex items-center gap-1"
>
<div className="i-ph:key w-4 h-4" />
Get your token
<div className="i-ph:arrow-square-out w-3 h-3" />
</a>
</div>
<div className="flex items-center justify-between mt-4">
<Button
onClick={handleConnect}
disabled={isConnecting || !tokenInput}
variant="default"
className="flex items-center gap-2"
>
{isConnecting ? (
<>
<div className="i-ph:spinner-gap animate-spin w-4 h-4" />
Connecting...
</>
) : (
<>
<CloudIcon className="w-4 h-4" />
Connect
</>
)}
</Button>
</div>
</div>
) : (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={handleDisconnect}
className={classNames(
'px-4 py-2 rounded-lg text-sm flex items-center gap-2',
'bg-red-500 text-white',
'hover:bg-red-600',
)}
>
<div className="i-ph:plug w-4 h-4" />
Disconnect
</button>
<span className="text-sm text-bolt-elements-textSecondary flex items-center gap-1">
<div className="i-ph:check-circle w-4 h-4 text-green-500" />
<div className="flex flex-col w-full gap-4 mt-4">
<div className="flex flex-wrap items-center gap-3">
<Button onClick={handleDisconnect} variant="destructive" size="sm" className="flex items-center gap-2">
<div className="i-ph:sign-out w-4 h-4" />
Disconnect
</Button>
<div className="flex items-center gap-2">
<CheckCircleIcon className="h-4 w-4 text-green-500" />
<span className="text-sm text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Connected to Netlify
</span>
</div>
</div>
<div className="flex items-center gap-4 p-4 bg-[#F8F8F8] dark:bg-[#1A1A1A] rounded-lg">
<img
src={connection.user.avatar_url}
referrerPolicy="no-referrer"
crossOrigin="anonymous"
alt={connection.user.full_name}
className="w-12 h-12 rounded-full border-2 border-[#00AD9F]"
/>
<div>
<h4 className="text-sm font-medium text-bolt-elements-textPrimary">{connection.user.full_name}</h4>
<p className="text-sm text-bolt-elements-textSecondary">{connection.user.email}</p>
</div>
</div>
{fetchingStats ? (
<div className="flex items-center gap-2 text-sm text-bolt-elements-textSecondary">
<div className="i-ph:spinner-gap w-4 h-4 animate-spin" />
Fetching Netlify sites...
</div>
) : (
<div>
<button
onClick={() => setIsSitesExpanded(!isSitesExpanded)}
className="w-full bg-transparent text-left text-sm font-medium text-bolt-elements-textPrimary mb-3 flex items-center gap-2"
<div className="flex items-center gap-2 ml-auto">
<Button
variant="outline"
onClick={() => window.open('https://app.netlify.com', '_blank', 'noopener,noreferrer')}
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
>
<div className="i-ph:buildings w-4 h-4" />
Your Sites ({connection.stats?.totalSites || 0})
<div
className={classNames(
'i-ph:caret-down w-4 h-4 ml-auto transition-transform',
isSitesExpanded ? 'rotate-180' : '',
)}
/>
</button>
{isSitesExpanded && connection.stats?.sites?.length ? (
<div className="grid gap-3">
{connection.stats.sites.map((site) => (
<a
key={site.id}
href={site.admin_url}
target="_blank"
rel="noopener noreferrer"
className="block p-4 rounded-lg border border-[#E5E5E5] dark:border-[#1A1A1A] hover:border-[#00AD9F] dark:hover:border-[#00AD9F] transition-colors"
>
<div className="flex items-center justify-between">
<div>
<h5 className="text-sm font-medium text-bolt-elements-textPrimary flex items-center gap-2">
<div className="i-ph:globe w-4 h-4 text-[#00AD9F]" />
{site.name}
</h5>
<div className="flex items-center gap-2 mt-2 text-xs text-bolt-elements-textSecondary">
<a
href={site.url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-[#00AD9F]"
>
{site.url}
</a>
{site.published_deploy && (
<>
<span></span>
<span className="flex items-center gap-1">
<div className="i-ph:clock w-3 h-3" />
{new Date(site.published_deploy.published_at).toLocaleDateString()}
</span>
</>
)}
</div>
</div>
{site.build_settings?.provider && (
<div className="text-xs text-bolt-elements-textSecondary px-2 py-1 rounded-md bg-[#F0F0F0] dark:bg-[#252525]">
<span className="flex items-center gap-1">
<div className="i-ph:git-branch w-3 h-3" />
{site.build_settings.provider}
</span>
</div>
)}
</div>
</a>
))}
</div>
) : isSitesExpanded ? (
<div className="text-sm text-bolt-elements-textSecondary flex items-center gap-2">
<div className="i-ph:info w-4 h-4" />
No sites found in your Netlify account
</div>
) : null}
<div className="i-ph:layout-dashboard w-4 h-4" />
Dashboard
</Button>
<Button
onClick={() => fetchNetlifyStats(connection.token)}
disabled={fetchingStats}
variant="outline"
className="flex items-center gap-2 hover:bg-bolt-elements-item-backgroundActive/10 hover:text-bolt-elements-textPrimary dark:hover:text-bolt-elements-textPrimary transition-colors"
>
{fetchingStats ? (
<>
<div className="i-ph:spinner-gap w-4 h-4 animate-spin text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary" />
<span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Refreshing...
</span>
</>
) : (
<>
<ArrowPathIcon className="h-4 w-4 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary" />
<span className="text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary">
Refresh Stats
</span>
</>
)}
</Button>
</div>
)}
</div>
{renderStats()}
</div>
)}
</div>
</motion.div>
</div>
);
}

View File

@@ -16,7 +16,7 @@ interface ConnectionFormProps {
export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }: ConnectionFormProps) {
// Check for saved token on mount
useEffect(() => {
const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || getLocalStorage(GITHUB_TOKEN_KEY);
const savedToken = Cookies.get(GITHUB_TOKEN_KEY) || Cookies.get('githubToken') || getLocalStorage(GITHUB_TOKEN_KEY);
if (savedToken && !authState.tokenInfo?.token) {
setAuthState((prev: GitHubAuthState) => ({
@@ -30,6 +30,9 @@ export function ConnectionForm({ authState, setAuthState, onSave, onDisconnect }
followers: 0,
},
}));
// Ensure the token is also saved with the correct key for API requests
Cookies.set('githubToken', savedToken);
}
}, []);

View File

@@ -1,4 +1,4 @@
import type { GitHubRepoInfo, GitHubContent, RepositoryStats } from '~/types/GitHub';
import type { GitHubRepoInfo, GitHubContent, RepositoryStats, GitHubUserResponse } from '~/types/GitHub';
import { useState, useEffect } from 'react';
import { toast } from 'react-toastify';
import * as Dialog from '@radix-ui/react-dialog';
@@ -7,6 +7,7 @@ import { getLocalStorage } from '~/lib/persistence';
import { motion } from 'framer-motion';
import { formatSize } from '~/utils/formatSize';
import { Input } from '~/components/ui/Input';
import Cookies from 'js-cookie';
interface GitHubTreeResponse {
tree: Array<{
@@ -122,6 +123,184 @@ function StatsDialog({ isOpen, onClose, onConfirm, stats, isLargeRepo }: StatsDi
);
}
function GitHubAuthDialog({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
const [token, setToken] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [tokenType, setTokenType] = useState<'classic' | 'fine-grained'>('classic');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token.trim()) {
return;
}
setIsSubmitting(true);
try {
const response = await fetch('https://api.github.com/user', {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const userData = (await response.json()) as GitHubUserResponse;
// Save connection data
const connectionData = {
token,
tokenType,
user: {
login: userData.login,
avatar_url: userData.avatar_url,
name: userData.name || userData.login,
},
connected_at: new Date().toISOString(),
};
localStorage.setItem('github_connection', JSON.stringify(connectionData));
// Set cookies for API requests
Cookies.set('githubToken', token);
Cookies.set('githubUsername', userData.login);
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
toast.success(`Successfully connected as ${userData.login}`);
onClose();
} else {
if (response.status === 401) {
toast.error('Invalid GitHub token. Please check and try again.');
} else {
toast.error(`GitHub API error: ${response.status} ${response.statusText}`);
}
}
} catch (error) {
console.error('Error connecting to GitHub:', error);
toast.error('Failed to connect to GitHub. Please try again.');
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[9999]" />
<div className="fixed inset-0 flex items-center justify-center z-[9999]">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2 }}
>
<Dialog.Content className="bg-white dark:bg-[#1A1A1A] rounded-lg shadow-xl max-w-sm w-full mx-4 overflow-hidden">
<div className="p-4 space-y-3">
<h2 className="text-lg font-semibold text-[#111111] dark:text-white">Access Private Repositories</h2>
<p className="text-sm text-[#666666] dark:text-[#999999]">
To access private repositories, you need to connect your GitHub account by providing a personal access
token.
</p>
<div className="bg-[#F9F9F9] dark:bg-[#252525] p-4 rounded-lg space-y-3">
<h3 className="text-base font-medium text-[#111111] dark:text-white">Connect with GitHub Token</h3>
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="block text-sm text-[#666666] dark:text-[#999999] mb-1">
GitHub Personal Access Token
</label>
<input
type="password"
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="ghp_xxxxxxxxxxxxxxxxxxxx"
className="w-full px-3 py-1.5 rounded-lg border border-[#E5E5E5] dark:border-[#333333] bg-white dark:bg-[#1A1A1A] text-[#111111] dark:text-white placeholder-[#999999] text-sm"
/>
<div className="mt-1 text-xs text-[#666666] dark:text-[#999999]">
Get your token at{' '}
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-purple-500 hover:underline"
>
github.com/settings/tokens
</a>
</div>
</div>
<div className="space-y-1">
<label className="block text-sm text-[#666666] dark:text-[#999999]">Token Type</label>
<div className="flex gap-4">
<label className="flex items-center gap-2">
<input
type="radio"
checked={tokenType === 'classic'}
onChange={() => setTokenType('classic')}
className="w-3.5 h-3.5 accent-purple-500"
/>
<span className="text-sm text-[#111111] dark:text-white">Classic</span>
</label>
<label className="flex items-center gap-2">
<input
type="radio"
checked={tokenType === 'fine-grained'}
onChange={() => setTokenType('fine-grained')}
className="w-3.5 h-3.5 accent-purple-500"
/>
<span className="text-sm text-[#111111] dark:text-white">Fine-grained</span>
</label>
</div>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full py-2 bg-purple-500 hover:bg-purple-600 text-white rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
{isSubmitting ? 'Connecting...' : 'Connect to GitHub'}
</button>
</form>
</div>
<div className="bg-amber-50 dark:bg-amber-900/20 p-3 rounded-lg space-y-1.5">
<h3 className="text-sm text-amber-800 dark:text-amber-300 font-medium flex items-center gap-1.5">
<span className="i-ph:warning-circle w-4 h-4" />
Accessing Private Repositories
</h3>
<p className="text-xs text-amber-700 dark:text-amber-400">
Important things to know about accessing private repositories:
</p>
<ul className="list-disc pl-4 text-xs text-amber-700 dark:text-amber-400 space-y-0.5">
<li>You must be granted access to the repository by its owner</li>
<li>Your GitHub token must have the 'repo' scope</li>
<li>For organization repositories, you may need additional permissions</li>
<li>No token can give you access to repositories you don't have permission for</li>
</ul>
</div>
</div>
<div className="border-t border-[#E5E5E5] dark:border-[#333333] p-3 flex justify-end">
<Dialog.Close asChild>
<button
onClick={onClose}
className="px-4 py-1.5 bg-[#F5F5F5] hover:bg-[#E5E5E5] dark:bg-[#252525] dark:hover:bg-[#333333] rounded-lg text-[#111111] dark:text-white transition-colors text-sm"
>
Close
</button>
</Dialog.Close>
</div>
</Dialog.Content>
</motion.div>
</div>
</Dialog.Portal>
</Dialog.Root>
);
}
export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: RepositorySelectionDialogProps) {
const [selectedRepository, setSelectedRepository] = useState<GitHubRepoInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
@@ -133,13 +312,78 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
const [branches, setBranches] = useState<{ name: string; default?: boolean }[]>([]);
const [selectedBranch, setSelectedBranch] = useState('');
const [filters, setFilters] = useState<SearchFilters>({});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [stats, setStats] = useState<RepositoryStats | null>(null);
const [showStatsDialog, setShowStatsDialog] = useState(false);
const [currentStats, setCurrentStats] = useState<RepositoryStats | null>(null);
const [pendingGitUrl, setPendingGitUrl] = useState<string>('');
const [showAuthDialog, setShowAuthDialog] = useState(false);
// Fetch user's repositories when dialog opens
// Handle GitHub auth dialog close and refresh repositories
const handleAuthDialogClose = () => {
setShowAuthDialog(false);
// If we're on the my-repos tab, refresh the repository list
if (activeTab === 'my-repos') {
fetchUserRepos();
}
};
// Initialize GitHub connection and fetch repositories
useEffect(() => {
const savedConnection = getLocalStorage('github_connection');
// If no connection exists but environment variables are set, create a connection
if (!savedConnection && import.meta.env.VITE_GITHUB_ACCESS_TOKEN) {
const token = import.meta.env.VITE_GITHUB_ACCESS_TOKEN;
const tokenType = import.meta.env.VITE_GITHUB_TOKEN_TYPE === 'fine-grained' ? 'fine-grained' : 'classic';
// Fetch GitHub user info to initialize the connection
fetch('https://api.github.com/user', {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token}`,
},
})
.then((response) => {
if (!response.ok) {
throw new Error('Invalid token or unauthorized');
}
return response.json();
})
.then((data: unknown) => {
const userData = data as GitHubUserResponse;
// Save connection to local storage
const newConnection = {
token,
tokenType,
user: {
login: userData.login,
avatar_url: userData.avatar_url,
name: userData.name || userData.login,
},
connected_at: new Date().toISOString(),
};
localStorage.setItem('github_connection', JSON.stringify(newConnection));
// Also save as cookies for API requests
Cookies.set('githubToken', token);
Cookies.set('githubUsername', userData.login);
Cookies.set('git:github.com', JSON.stringify({ username: token, password: 'x-oauth-basic' }));
// Refresh repositories after connection is established
if (isOpen && activeTab === 'my-repos') {
fetchUserRepos();
}
})
.catch((error) => {
console.error('Failed to initialize GitHub connection from environment variables:', error);
});
}
}, [isOpen]);
// Fetch repositories when dialog opens or tab changes
useEffect(() => {
if (isOpen && activeTab === 'my-repos') {
fetchUserRepos();
@@ -159,6 +403,7 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
try {
const response = await fetch('https://api.github.com/user/repos?sort=updated&per_page=100&type=all', {
headers: {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${connection.token}`,
},
});
@@ -238,10 +483,15 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
setIsLoading(true);
try {
const connection = getLocalStorage('github_connection');
const headers: HeadersInit = connection?.token
? {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${connection.token}`,
}
: {};
const response = await fetch(`https://api.github.com/repos/${repo.full_name}/branches`, {
headers: {
Authorization: `Bearer ${getLocalStorage('github_connection')?.token}`,
},
headers,
});
if (!response.ok) {
@@ -285,34 +535,97 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
const verifyRepository = async (repoUrl: string): Promise<RepositoryStats | null> => {
try {
const [owner, repo] = repoUrl
// Extract branch from URL if present (format: url#branch)
let branch: string | null = null;
let cleanUrl = repoUrl;
if (repoUrl.includes('#')) {
const parts = repoUrl.split('#');
cleanUrl = parts[0];
branch = parts[1];
}
const [owner, repo] = cleanUrl
.replace(/\.git$/, '')
.split('/')
.slice(-2);
// Try to get token from local storage first
const connection = getLocalStorage('github_connection');
const headers: HeadersInit = connection?.token ? { Authorization: `Bearer ${connection.token}` } : {};
const repoObjResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers,
});
const repoObjData = (await repoObjResponse.json()) as any;
if (!repoObjData.default_branch) {
throw new Error('Failed to fetch repository branch');
// If no connection in local storage, check environment variables
let headers: HeadersInit = {};
if (connection?.token) {
headers = {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${connection.token}`,
};
} else if (import.meta.env.VITE_GITHUB_ACCESS_TOKEN) {
// Use token from environment variables
headers = {
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${import.meta.env.VITE_GITHUB_ACCESS_TOKEN}`,
};
}
const defaultBranch = repoObjData.default_branch;
// First, get the repository info to determine the default branch
const repoInfoResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
headers,
});
// Fetch repository tree
const treeResponse = await fetch(
if (!repoInfoResponse.ok) {
if (repoInfoResponse.status === 401 || repoInfoResponse.status === 403) {
throw new Error(
`Authentication failed (${repoInfoResponse.status}). Your GitHub token may be invalid or missing the required permissions.`,
);
} else if (repoInfoResponse.status === 404) {
throw new Error(
`Repository not found or is private (${repoInfoResponse.status}). To access private repositories, you need to connect your GitHub account or provide a valid token with appropriate permissions.`,
);
} else {
throw new Error(
`Failed to fetch repository information: ${repoInfoResponse.statusText} (${repoInfoResponse.status})`,
);
}
}
const repoInfo = (await repoInfoResponse.json()) as { default_branch: string };
let defaultBranch = repoInfo.default_branch || 'main';
// If a branch was specified in the URL, use that instead of the default
if (branch) {
defaultBranch = branch;
}
// Try to fetch the repository tree using the selected branch
let treeResponse = await fetch(
`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`,
{
headers,
},
);
// If the selected branch doesn't work, try common branch names
if (!treeResponse.ok) {
throw new Error('Failed to fetch repository structure');
// Try 'master' branch if default branch failed
treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/master?recursive=1`, {
headers,
});
// If master also fails, try 'main' branch
if (!treeResponse.ok) {
treeResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/main?recursive=1`, {
headers,
});
}
// If all common branches fail, throw an error
if (!treeResponse.ok) {
throw new Error(
'Failed to fetch repository structure. Please check the repository URL and your access permissions.',
);
}
}
const treeData = (await treeResponse.json()) as GitHubTreeResponse;
@@ -369,12 +682,27 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
hasDependencies,
};
setStats(stats);
return stats;
} catch (error) {
console.error('Error verifying repository:', error);
toast.error('Failed to verify repository');
// Check if it's an authentication error and show the auth dialog
const errorMessage = error instanceof Error ? error.message : 'Failed to verify repository';
if (
errorMessage.includes('Authentication failed') ||
errorMessage.includes('may be private') ||
errorMessage.includes('Repository not found or is private') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('401') ||
errorMessage.includes('403') ||
errorMessage.includes('404') ||
errorMessage.includes('access permissions')
) {
setShowAuthDialog(true);
}
toast.error(errorMessage);
return null;
}
@@ -408,7 +736,36 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
setShowStatsDialog(true);
} catch (error) {
console.error('Error preparing repository:', error);
toast.error('Failed to prepare repository. Please try again.');
// Check if it's an authentication error
const errorMessage = error instanceof Error ? error.message : 'Failed to prepare repository. Please try again.';
// Show the GitHub auth dialog for any authentication or permission errors
if (
errorMessage.includes('Authentication failed') ||
errorMessage.includes('may be private') ||
errorMessage.includes('Repository not found or is private') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('401') ||
errorMessage.includes('403') ||
errorMessage.includes('404') ||
errorMessage.includes('access permissions')
) {
// Directly show the auth dialog instead of just showing a toast
setShowAuthDialog(true);
toast.error(
<div className="space-y-2">
<p>{errorMessage}</p>
<button onClick={() => setShowAuthDialog(true)} className="underline font-medium block text-purple-500">
Learn how to access private repositories
</button>
</div>,
{ autoClose: 10000 }, // Keep the toast visible longer
);
} else {
toast.error(errorMessage);
}
}
};
@@ -441,182 +798,210 @@ export function RepositorySelectionDialog({ isOpen, onClose, onSelect }: Reposit
};
return (
<Dialog.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) {
handleClose();
}
}}
>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" />
<Dialog.Content className="fixed top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2 w-[90vw] md:w-[600px] max-h-[85vh] overflow-hidden bg-white dark:bg-[#1A1A1A] rounded-xl shadow-xl z-[51] border border-[#E5E5E5] dark:border-[#333333]">
<div className="p-4 border-b border-[#E5E5E5] dark:border-[#333333] flex items-center justify-between">
<Dialog.Title className="text-lg font-semibold text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
Import GitHub Repository
</Dialog.Title>
<Dialog.Close
onClick={handleClose}
className={classNames(
'p-2 rounded-lg transition-all duration-200 ease-in-out',
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary',
'dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark',
'hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3',
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark',
)}
>
<span className="i-ph:x block w-5 h-5" aria-hidden="true" />
<span className="sr-only">Close dialog</span>
</Dialog.Close>
</div>
<div className="p-4">
<div className="flex gap-2 mb-4">
<TabButton active={activeTab === 'my-repos'} onClick={() => setActiveTab('my-repos')}>
<span className="i-ph:book-bookmark" />
My Repos
</TabButton>
<TabButton active={activeTab === 'search'} onClick={() => setActiveTab('search')}>
<span className="i-ph:magnifying-glass" />
Search
</TabButton>
<TabButton active={activeTab === 'url'} onClick={() => setActiveTab('url')}>
<span className="i-ph:link" />
URL
</TabButton>
<>
<Dialog.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) {
handleClose();
}
}}
>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" />
<Dialog.Content className="fixed top-[50%] left-[50%] -translate-x-1/2 -translate-y-1/2 w-[90vw] md:w-[600px] max-h-[85vh] overflow-hidden bg-white dark:bg-[#1A1A1A] rounded-xl shadow-xl z-[51] border border-[#E5E5E5] dark:border-[#333333]">
<div className="p-4 border-b border-[#E5E5E5] dark:border-[#333333] flex items-center justify-between">
<Dialog.Title className="text-lg font-semibold text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark">
Import GitHub Repository
</Dialog.Title>
<Dialog.Close
onClick={handleClose}
className={classNames(
'p-2 rounded-lg transition-all duration-200 ease-in-out',
'text-bolt-elements-textTertiary hover:text-bolt-elements-textPrimary',
'dark:text-bolt-elements-textTertiary-dark dark:hover:text-bolt-elements-textPrimary-dark',
'hover:bg-bolt-elements-background-depth-2 dark:hover:bg-bolt-elements-background-depth-3',
'focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark',
)}
>
<span className="i-ph:x block w-5 h-5" aria-hidden="true" />
<span className="sr-only">Close dialog</span>
</Dialog.Close>
</div>
{activeTab === 'url' ? (
<div className="space-y-4">
<Input
placeholder="Enter repository URL"
value={customUrl}
onChange={(e) => setCustomUrl(e.target.value)}
className={classNames('w-full', {
'border-red-500': false,
})}
/>
<button
onClick={handleImport}
disabled={!customUrl}
className="w-full h-10 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 flex items-center gap-2 justify-center"
>
Import Repository
</button>
<div className="p-4 border-b border-[#E5E5E5] dark:border-[#333333] flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="i-ph:info text-blue-500" />
<span className="text-sm text-bolt-elements-textSecondary dark:text-bolt-elements-textSecondary-dark">
Need to access private repositories?
</span>
</div>
) : (
<>
{activeTab === 'search' && (
<div className="space-y-4 mb-4">
<div className="flex gap-2">
<input
type="text"
placeholder="Search repositories..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
handleSearch(e.target.value);
}}
className="flex-1 px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] text-bolt-elements-textPrimary"
/>
<button
onClick={() => setFilters({})}
className="px-3 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
>
<span className="i-ph:funnel-simple" />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<input
type="text"
placeholder="Filter by language..."
value={filters.language || ''}
onChange={(e) => {
setFilters({ ...filters, language: e.target.value });
handleSearch(searchQuery);
}}
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
/>
<button
onClick={() => setShowAuthDialog(true)}
className="px-3 py-1.5 rounded-lg bg-purple-500 hover:bg-purple-600 text-white text-sm transition-colors flex items-center gap-1.5"
>
<span className="i-ph:key" />
Connect GitHub Account
</button>
</div>
<div className="p-4">
<div className="flex gap-2 mb-4">
<TabButton active={activeTab === 'my-repos'} onClick={() => setActiveTab('my-repos')}>
<span className="i-ph:book-bookmark" />
My Repos
</TabButton>
<TabButton active={activeTab === 'search'} onClick={() => setActiveTab('search')}>
<span className="i-ph:magnifying-glass" />
Search
</TabButton>
<TabButton active={activeTab === 'url'} onClick={() => setActiveTab('url')}>
<span className="i-ph:link" />
URL
</TabButton>
</div>
{activeTab === 'url' ? (
<div className="space-y-4">
<Input
type="text"
placeholder="Enter GitHub repository URL"
value={customUrl}
onChange={(e) => setCustomUrl(e.target.value)}
className="w-full"
/>
<button
onClick={handleImport}
disabled={!customUrl}
className={classNames(
'w-full h-10 px-4 py-2 rounded-lg text-white transition-all duration-200 flex items-center gap-2 justify-center',
customUrl
? 'bg-purple-500 hover:bg-purple-600'
: 'bg-gray-300 dark:bg-gray-700 cursor-not-allowed',
)}
>
Import Repository
</button>
</div>
) : (
<>
{activeTab === 'search' && (
<div className="space-y-4 mb-4">
<div className="flex gap-2">
<input
type="text"
placeholder="Search repositories..."
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
handleSearch(e.target.value);
}}
className="flex-1 px-4 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] text-bolt-elements-textPrimary"
/>
<button
onClick={() => setFilters({})}
className="px-3 py-2 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary"
>
<span className="i-ph:funnel-simple" />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<input
type="text"
placeholder="Filter by language..."
value={filters.language || ''}
onChange={(e) => {
setFilters({ ...filters, language: e.target.value });
handleSearch(searchQuery);
}}
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
/>
<input
type="number"
placeholder="Min stars..."
value={filters.stars || ''}
onChange={(e) => handleFilterChange('stars', e.target.value)}
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
/>
</div>
<input
type="number"
placeholder="Min stars..."
value={filters.stars || ''}
onChange={(e) => handleFilterChange('stars', e.target.value)}
placeholder="Min forks..."
value={filters.forks || ''}
onChange={(e) => handleFilterChange('forks', e.target.value)}
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
/>
</div>
<input
type="number"
placeholder="Min forks..."
value={filters.forks || ''}
onChange={(e) => handleFilterChange('forks', e.target.value)}
className="px-3 py-1.5 text-sm rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333]"
/>
</div>
)}
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
{selectedRepository ? (
<div className="space-y-4">
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedRepository(null)}
className="p-1.5 rounded-lg hover:bg-[#F5F5F5] dark:hover:bg-[#252525]"
>
<span className="i-ph:arrow-left w-4 h-4" />
</button>
<h3 className="font-medium">{selectedRepository.full_name}</h3>
</div>
<div className="space-y-2">
<label className="text-sm text-bolt-elements-textSecondary">Select Branch</label>
<select
value={selectedBranch}
onChange={(e) => setSelectedBranch(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark"
>
{branches.map((branch) => (
<option
key={branch.name}
value={branch.name}
className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
>
{branch.name} {branch.default ? '(default)' : ''}
</option>
))}
</select>
<button
onClick={handleImport}
className="w-full h-10 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 justify-center"
>
Import Selected Branch
</button>
</div>
</div>
) : (
<RepositoryList
repos={activeTab === 'my-repos' ? repositories : searchResults}
isLoading={isLoading}
onSelect={handleRepoSelect}
activeTab={activeTab}
/>
)}
</div>
</>
)}
</div>
</Dialog.Content>
</Dialog.Portal>
{currentStats && (
<StatsDialog
isOpen={showStatsDialog}
onClose={handleStatsConfirm}
onConfirm={handleStatsConfirm}
stats={currentStats}
isLargeRepo={currentStats.totalSize > 50 * 1024 * 1024}
/>
)}
</Dialog.Root>
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-2 custom-scrollbar">
{selectedRepository ? (
<div className="space-y-4">
<div className="flex items-center gap-2">
<button
onClick={() => setSelectedRepository(null)}
className="p-1.5 rounded-lg hover:bg-[#F5F5F5] dark:hover:bg-[#252525]"
>
<span className="i-ph:arrow-left w-4 h-4" />
</button>
<h3 className="font-medium">{selectedRepository.full_name}</h3>
</div>
<div className="space-y-2">
<label className="text-sm text-bolt-elements-textSecondary">Select Branch</label>
<select
value={selectedBranch}
onChange={(e) => setSelectedBranch(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor dark:border-bolt-elements-borderColor-dark text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColor dark:focus:ring-bolt-elements-borderColor-dark"
>
{branches.map((branch) => (
<option
key={branch.name}
value={branch.name}
className="bg-bolt-elements-background-depth-2 dark:bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary dark:text-bolt-elements-textPrimary-dark"
>
{branch.name} {branch.default ? '(default)' : ''}
</option>
))}
</select>
<button
onClick={handleImport}
className="w-full h-10 px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all duration-200 flex items-center gap-2 justify-center"
>
Import Selected Branch
</button>
</div>
</div>
) : (
<RepositoryList
repos={activeTab === 'my-repos' ? repositories : searchResults}
isLoading={isLoading}
onSelect={handleRepoSelect}
activeTab={activeTab}
/>
)}
</div>
</>
)}
</div>
</Dialog.Content>
</Dialog.Portal>
{/* GitHub Auth Dialog */}
<GitHubAuthDialog isOpen={showAuthDialog} onClose={handleAuthDialogClose} />
{/* Repository Stats Dialog */}
{currentStats && (
<StatsDialog
isOpen={showStatsDialog}
onClose={() => setShowStatsDialog(false)}
onConfirm={handleStatsConfirm}
stats={currentStats}
isLargeRepo={currentStats.totalSize > 50 * 1024 * 1024}
/>
)}
</Dialog.Root>
</>
);
}
@@ -670,7 +1055,7 @@ function RepositoryList({
function RepositoryCard({ repo, onSelect }: { repo: GitHubRepoInfo; onSelect: () => void }) {
return (
<div className="p-4 rounded-lg bg-[#F5F5F5] dark:bg-[#252525] border border-[#E5E5E5] dark:border-[#333333] hover:border-purple-500/50 transition-colors">
<div className="p-4 rounded-lg bg-bolt-elements-background-depth-1 border border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive/70 transition-colors">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="i-ph:git-repository text-bolt-elements-textTertiary" />