Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 22 additions & 13 deletions web/src/components/layout/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ProjectFormDialog } from '@/components/projects/project-form-dialog.js';
import {
Select,
SelectContent,
Expand All @@ -20,6 +21,7 @@ import {
ChevronDown,
ChevronRight,
FolderGit2,
Plus,
Settings,
Users,
Zap,
Expand Down Expand Up @@ -174,6 +176,7 @@ export function Sidebar({ user }: SidebarProps) {
const currentPath = routerState.location.pathname;

const { data: projects } = useQuery(trpc.projects.list.queryOptions());
const [createDialogOpen, setCreateDialogOpen] = useState(false);

return (
<div className="flex w-56 flex-col border-r border-sidebar-border bg-sidebar">
Expand All @@ -186,30 +189,36 @@ export function Sidebar({ user }: SidebarProps) {

<Separator className="my-3" />

<div className="px-3 py-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Projects
<div className="flex items-center justify-between px-3 py-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Projects
</span>
<button
type="button"
onClick={() => setCreateDialogOpen(true)}
className="flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground transition-colors"
title="New Project"
>
<Plus className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex flex-col gap-0.5">
{projects && projects.length > 0 ? (
projects.map((project) => (
<ProjectNavItem key={project.id} project={project} currentPath={currentPath} />
))
) : (
<Link
to="/projects"
className="block px-3 py-2 text-sm text-muted-foreground hover:text-foreground"
<button
type="button"
onClick={() => setCreateDialogOpen(true)}
className="block px-3 py-2 text-sm text-muted-foreground hover:text-foreground text-left"
>
+ Create a project
</Link>
</button>
)}
</div>
<NavLink
to="/projects"
label="All Projects"
icon={FolderGit2}
currentPath={currentPath}
exact
/>

<ProjectFormDialog open={createDialogOpen} onOpenChange={setCreateDialogOpen} />

<Separator className="my-3" />

Expand Down
109 changes: 60 additions & 49 deletions web/src/components/projects/integration-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
AlertCircle,
AlertTriangle,
ChevronDown,
ChevronRight,
Check,
Clipboard,
ExternalLink,
KeyRound,
Info,
Loader2,
RefreshCw,
Trash2,
Expand Down Expand Up @@ -97,21 +97,33 @@ function GitHubCredentialSlots({ projectId }: { projectId: string }) {
// GitHub Webhook Management
// ============================================================================

function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center gap-1 shrink-0 rounded px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
title="Copy to clipboard"
>
{copied ? <Check className="h-3 w-3 text-green-600" /> : <Clipboard className="h-3 w-3" />}
{copied ? 'Copied' : 'Copy'}
</button>
);
}

function GitHubWebhookSection({ projectId }: { projectId: string }) {
const queryClient = useQueryClient();

const callbackBaseUrl =
API_URL ||
(typeof window !== 'undefined' ? window.location.origin.replace(':5173', ':3000') : '');

const [adminTokensOpen, setAdminTokensOpen] = useState(false);
const [oneTimeGithubToken, setOneTimeGithubToken] = useState('');

const buildOneTimeTokens = () => {
if (oneTimeGithubToken) return { github: oneTimeGithubToken };
return undefined;
};

const webhooksQuery = useQuery(trpc.webhooks.list.queryOptions({ projectId }));

const createGithubWebhookMutation = useMutation({
Expand All @@ -120,10 +132,8 @@ function GitHubWebhookSection({ projectId }: { projectId: string }) {
projectId,
callbackBaseUrl,
githubOnly: true,
oneTimeTokens: buildOneTimeTokens(),
}),
onSuccess: () => {
setOneTimeGithubToken('');
queryClient.invalidateQueries({
queryKey: trpc.webhooks.list.queryOptions({ projectId }).queryKey,
});
Expand All @@ -136,7 +146,6 @@ function GitHubWebhookSection({ projectId }: { projectId: string }) {
projectId,
callbackBaseUrl: deleteCallbackBaseUrl,
githubOnly: true,
oneTimeTokens: buildOneTimeTokens(),
}),
onSuccess: () => {
queryClient.invalidateQueries({
Expand All @@ -151,6 +160,24 @@ function GitHubWebhookSection({ projectId }: { projectId: string }) {
active: w.active,
}));

const webhookCallbackUrl = callbackBaseUrl
? `${callbackBaseUrl}/github/webhook`
: '<YOUR_CALLBACK_URL>/github/webhook';
const githubCurlCommand = [
'curl -X POST "https://api.github.com/repos/<OWNER>/<REPO>/hooks" \\',
' -H "Authorization: Bearer <YOUR_GITHUB_TOKEN>" \\',
' -H "Content-Type: application/json" \\',
" -d '{",
' "name": "web",',
' "active": true,',
' "events": ["push", "pull_request", "check_suite", "pull_request_review"],',
' "config": {',
` "url": "${webhookCallbackUrl}",`,
' "content_type": "json"',
' }',
" }'",
].join('\n');

return (
<div className="space-y-4">
<div>
Expand Down Expand Up @@ -244,42 +271,26 @@ function GitHubWebhookSection({ projectId }: { projectId: string }) {
)}
</div>

{/* One-time admin credentials */}
<div className="border rounded-md">
<button
type="button"
onClick={() => setAdminTokensOpen((prev) => !prev)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<KeyRound className="h-4 w-4" />
<span className="flex-1">Use admin credentials (one-time)</span>
{adminTokensOpen ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
{adminTokensOpen && (
<div className="border-t px-3 py-3 space-y-3">
<p className="text-xs text-muted-foreground">
Provide a token with elevated permissions for webhook management. This is used once
and never saved.
</p>
<div className="space-y-1">
<Label className="text-xs">
GitHub PAT{' '}
<span className="text-muted-foreground font-normal">(admin:repo_hook scope)</span>
</Label>
<Input
value={oneTimeGithubToken}
onChange={(e) => setOneTimeGithubToken(e.target.value)}
placeholder="ghp_... — used once, not saved"
type="password"
className="h-8 text-sm"
/>
</div>
{/* curl instructions for manual GitHub webhook creation */}
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-3 space-y-2 dark:border-blue-900/50 dark:bg-blue-900/20">
<div className="flex items-start gap-2">
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
<p className="text-xs text-blue-700 dark:text-blue-300 font-medium">
Manual webhook creation (if the button above doesn't work)
</p>
</div>
<p className="text-xs text-blue-600 dark:text-blue-400 pl-6">
Use the following curl command to create the GitHub webhook manually. Requires a token
with <code>admin:repo_hook</code> scope.
</p>
<div className="relative rounded-md bg-muted border pl-6">
<div className="absolute top-2 right-2">
<CopyButton text={githubCurlCommand} />
</div>
)}
<pre className="text-xs font-mono whitespace-pre-wrap break-all py-2 pr-16 overflow-x-auto">
{githubCurlCommand}
</pre>
</div>
</div>
</div>
);
Expand Down
Loading
Loading