Connected to backend and fixed up UI changes, created a test-connection page

This commit is contained in:
NasSharaf 2025-06-12 16:16:08 -04:00
parent 16bb8acc7e
commit 4512ef1d8a
41 changed files with 6172 additions and 1661 deletions

View File

@ -3,14 +3,15 @@
import { useParams } from 'next/navigation';
import { PageWrapper } from '@/components/foundation';
import { DeploymentDetailsCard } from '@/components/projects/project/deployments/DeploymentDetailsCard';
import { FilterForm } from '@/components/projects/project/deployments/FilterForm';
import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { IconButton } from '@workspace/ui/components/button';
import { Rocket } from 'lucide-react';
import { Button } from '@workspace/ui/components/button';
import { Square, Search, Calendar, ChevronDown } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useRepoData } from '@/hooks/useRepoData';
import type { Deployment, Domain } from '@/types';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@workspace/ui/components/dialog';
import { Input } from '@workspace/ui/components/input';
export default function DeploymentsPage() {
const router = useRouter();
@ -26,6 +27,11 @@ export default function DeploymentsPage() {
const [deployments, setDeployments] = useState<Deployment[]>([]);
const [filteredDeployments, setFilteredDeployments] = useState<Deployment[]>([]);
const [prodBranchDomains, setProdBranchDomains] = useState<Domain[]>([]);
// State for deployment logs modal
const [isLogsOpen, setIsLogsOpen] = useState(false);
const [selectedDeploymentId, setSelectedDeploymentId] = useState<string | null>(null);
const [deploymentLogs, setDeploymentLogs] = useState<string>('');
// Create a default deployment
const defaultDeployment: Deployment = {
@ -56,11 +62,17 @@ export default function DeploymentsPage() {
}
};
// Initialize with mock data
// Initialize with empty data for testing the empty state
// Comment this out to see the mock deployments
useEffect(() => {
const mockDeployments = [defaultDeployment, secondDeployment];
setDeployments(mockDeployments);
setFilteredDeployments(mockDeployments);
// For testing the empty state
setDeployments([]);
setFilteredDeployments([]);
// Uncomment to see mock deployments
// const mockDeployments = [defaultDeployment, secondDeployment];
// setDeployments(mockDeployments);
// setFilteredDeployments(mockDeployments);
// Mock domains
const mockDomains: Domain[] = [
@ -102,6 +114,31 @@ export default function DeploymentsPage() {
setFilteredDeployments(deployments);
};
// View logs handler
const handleViewLogs = (deploymentId: string) => {
setSelectedDeploymentId(deploymentId);
// Mock logs data
const mockLogs = `[2025-02-12 10:03:12] INFO Starting deployment process for service: api-gateway
[2025-02-12 10:03:14] INFO Fetching latest commit from main branch (commit: a1b2c3d)
[2025-02-12 10:03:15] INFO Building Docker image: registry.company.com/api-gateway:latest
[2025-02-12 10:03:26] INFO Running security scan on built image
[2025-02-12 10:03:27] WARNING Medium severity vulnerability detected in package 'openssl'
[2025-02-12 10:03:30] INFO Pushing image to container registry
[2025-02-12 10:03:35] INFO Updating Kubernetes deployment
[2025-02-12 10:03:40] INFO Scaling down old pods
[2025-02-12 10:03:42] INFO Scaling up new pods
[2025-02-12 10:03:50] INFO Running health checks on new pods
[2025-02-12 10:03:52] ERROR Pod 'api-gateway-7df9bbb500-tx2k4' failed readiness probe (502 Bad Gateway)
[2025-02-12 10:03:55] INFO Retrying deployment with previous stable image
[2025-02-12 10:04:03] INFO Rolling back to registry.company.com/api-gateway:previous
[2025-02-12 10:04:10] INFO Deployment rolled back successfully
[2025-02-12 10:04:11] ERROR Deployment failed, please review logs and fix errors`;
setDeploymentLogs(mockLogs);
setIsLogsOpen(true);
};
const project = {
id: id,
prodBranch: 'main',
@ -110,6 +147,8 @@ export default function DeploymentsPage() {
const currentDeployment = deployments.find(deployment => deployment.isCurrent) || defaultDeployment;
const hasDeployments = deployments.length > 0;
return (
<PageWrapper
header={{
@ -145,41 +184,108 @@ export default function DeploymentsPage() {
</Tabs>
<div className="mt-6">
<FilterForm />
{/* Filter Controls - Always visible but disabled when no deployments */}
<div className="flex flex-wrap gap-4 mb-4">
{/* Search box */}
<div className="relative flex-grow max-w-md">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-4 w-4 text-gray-400" />
</div>
<Input
type="text"
placeholder="Search branches"
className="pl-10"
disabled={!hasDeployments}
/>
</div>
{/* Date selector */}
<div className="relative">
<Button
variant="outline"
className="flex items-center opacity-60"
disabled={!hasDeployments}
>
<Calendar className="h-4 w-4 mr-2" />
<span>Select a date</span>
</Button>
</div>
{/* Status dropdown */}
<div className="relative">
<Button
variant="outline"
className="flex items-center opacity-60"
disabled={!hasDeployments}
>
<span>All Status</span>
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
</div>
</div>
<div className="h-full mt-4">
{filteredDeployments.length > 0 ? (
filteredDeployments.map((deployment) => (
<DeploymentDetailsCard
key={deployment.id}
deployment={deployment}
currentDeployment={currentDeployment}
project={project}
prodBranchDomains={prodBranchDomains}
/>
<div key={deployment.id} className="mb-4">
<DeploymentDetailsCard
deployment={deployment}
currentDeployment={currentDeployment}
project={project}
prodBranchDomains={prodBranchDomains}
/>
<div className="mt-2 flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => handleViewLogs(deployment.id)}
>
View logs
</Button>
</div>
</div>
))
) : (
<div className="h-96 bg-base-bg-alternate dark:bg-overlay3 rounded-xl flex flex-col items-center justify-center gap-5 text-center">
<div className="space-y-1">
<p className="font-medium tracking-[-0.011em] text-elements-high-em dark:text-foreground">
No deployments found
</p>
<p className="text-sm tracking-[-0.006em] text-elements-mid-em dark:text-foreground-secondary">
Please change your search query or filters.
</p>
// Updated empty state to match screenshot
<div className="h-96 border border-gray-800 rounded-lg flex flex-col items-center justify-center gap-5 text-center">
<div className="mb-6">
<Square size={64} className="stroke-current" />
</div>
<IconButton
<h2 className="text-xl font-semibold mb-2">You have no deployments</h2>
<p className="text-gray-400 text-center max-w-md mb-6">
Please change your search query or filters.
</p>
<Button
variant="outline"
size="sm"
leftIcon={<Rocket className="w-4 h-4" />}
onClick={handleResetFilters}
>
RESET FILTERS
</IconButton>
Reset filters
</Button>
</div>
)}
</div>
</div>
</div>
{/* Deployment Logs Modal */}
<Dialog open={isLogsOpen} onOpenChange={setIsLogsOpen}>
<DialogContent className="max-w-4xl max-h-[80vh] h-[80vh] flex flex-col">
<DialogHeader>
<DialogTitle>Deployment Logs</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto mt-4">
<pre className="bg-black text-green-400 p-4 rounded text-sm font-mono whitespace-pre overflow-x-auto">
{deploymentLogs}
</pre>
</div>
<div className="flex justify-end mt-4">
<Button variant="secondary" onClick={() => setIsLogsOpen(false)}>
Close
</Button>
</div>
</DialogContent>
</Dialog>
</PageWrapper>
);
}

View File

@ -1,7 +1,6 @@
"use client";
import { useState } from "react";
import { useRouter, useParams } from "next/navigation";
import { LoadingOverlay } from "@/components/foundation/loading/loading-overlay";
interface SwitchProps {
@ -26,12 +25,12 @@ function Switch({ id, checked, onChange, disabled = false }: SwitchProps) {
disabled={disabled}
/>
<div
className={`relative w-11 h-6 bg-gray-800 rounded-full transition-colors
${checked ? 'bg-blue-600' : 'bg-gray-700'}`}
className={`relative w-11 h-6 bg-muted rounded-full transition-colors
${checked ? 'bg-primary' : 'bg-muted'}`}
>
<div
className={`absolute w-4 h-4 bg-white rounded-full transition-transform transform
${checked ? 'translate-x-6' : 'translate-x-1'} top-1`}
className={`absolute w-4 h-4 bg-background rounded-full transition-transform transform
${checked ? 'translate-x-6' : 'translate-x-1'} top-1 border border-border`}
></div>
</div>
</label>
@ -39,9 +38,6 @@ function Switch({ id, checked, onChange, disabled = false }: SwitchProps) {
}
export default function GitPage() {
const params = useParams();
const { provider, id } = params;
const [pullRequestComments, setPullRequestComments] = useState(true);
const [commitComments, setCommitComments] = useState(false);
const [productionBranch, setProductionBranch] = useState("main");
@ -88,8 +84,8 @@ export default function GitPage() {
{(isSavingBranch || isSavingWebhook) && <LoadingOverlay />}
<div className="space-y-8 w-full">
<div className="rounded-lg border border-gray-800 p-6 bg-black">
<h2 className="text-xl font-semibold mb-4">Git repository</h2>
<div className="rounded-lg border border-border p-6 bg-card">
<h2 className="text-xl font-semibold mb-4 text-foreground">Git repository</h2>
<div className="space-y-6">
<div className="flex items-start justify-between">
@ -100,11 +96,11 @@ export default function GitPage() {
checked={pullRequestComments}
onChange={setPullRequestComments}
/>
<label htmlFor="pull-request-comments" className="text-sm font-medium">
<label htmlFor="pull-request-comments" className="text-sm font-medium text-foreground">
Pull request comments
</label>
</div>
<p className="text-sm text-gray-400 mt-1 ml-14">
<p className="text-sm text-muted-foreground mt-1 ml-14">
Laconic will comment on pull requests opened against this project.
</p>
</div>
@ -118,11 +114,11 @@ export default function GitPage() {
checked={commitComments}
onChange={setCommitComments}
/>
<label htmlFor="commit-comments" className="text-sm font-medium">
<label htmlFor="commit-comments" className="text-sm font-medium text-foreground">
Commit comments
</label>
</div>
<p className="text-sm text-gray-400 mt-1 ml-14">
<p className="text-sm text-muted-foreground mt-1 ml-14">
Laconic will comment on commits deployed to production.
</p>
</div>
@ -130,47 +126,47 @@ export default function GitPage() {
</div>
</div>
<div className="rounded-lg border border-gray-800 p-6 bg-black">
<h2 className="text-xl font-semibold mb-4">Production branch</h2>
<div className="rounded-lg border border-border p-6 bg-card">
<h2 className="text-xl font-semibold mb-4 text-foreground">Production branch</h2>
<p className="text-sm text-gray-400 mb-4">
<p className="text-sm text-muted-foreground mb-4">
By default, each commit pushed to the main branch initiates a production deployment. You can opt for a
different branch for deployment in the settings.
</p>
<div className="space-y-4">
<div>
<label htmlFor="branch-name" className="block text-sm font-medium mb-1">
<label htmlFor="branch-name" className="block text-sm font-medium mb-1 text-foreground">
Branch name
</label>
<input
id="branch-name"
value={productionBranch}
onChange={(e) => setProductionBranch(e.target.value)}
className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
className="w-full px-3 py-2 rounded-md bg-background border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<button
className="px-4 py-2 border border-gray-600 rounded-md hover:bg-gray-800 transition-colors"
className="px-4 py-2 border border-border rounded-md hover:bg-accent hover:text-accent-foreground transition-colors bg-background text-foreground"
onClick={handleSaveBranch}
disabled={isSavingBranch}
>
Save
{isSavingBranch ? "Saving..." : "Save"}
</button>
</div>
</div>
<div className="rounded-lg border border-gray-800 p-6 bg-black">
<h2 className="text-xl font-semibold mb-4">Deploy webhooks</h2>
<div className="rounded-lg border border-border p-6 bg-card">
<h2 className="text-xl font-semibold mb-4 text-foreground">Deploy webhooks</h2>
<p className="text-sm text-gray-400 mb-4">
<p className="text-sm text-muted-foreground mb-4">
Webhooks configured to trigger when there is a change in a project's build or deployment status.
</p>
<div className="space-y-4">
<div>
<label htmlFor="webhook-url" className="block text-sm font-medium mb-1">
<label htmlFor="webhook-url" className="block text-sm font-medium mb-1 text-foreground">
Webhook URL
</label>
<div className="flex">
@ -179,14 +175,14 @@ export default function GitPage() {
value={webhookUrl}
onChange={(e) => setWebhookUrl(e.target.value)}
placeholder="https://"
className="flex-1 px-3 py-2 rounded-l-md bg-gray-900 border border-gray-700 text-white"
className="flex-1 px-3 py-2 rounded-l-md bg-background border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
<button
className="px-4 py-2 border border-gray-600 border-l-0 rounded-r-md hover:bg-gray-800 transition-colors"
className="px-4 py-2 border border-border border-l-0 rounded-r-md hover:bg-accent hover:text-accent-foreground transition-colors bg-background text-foreground"
onClick={handleSaveWebhook}
disabled={isSavingWebhook}
>
Save
{isSavingWebhook ? "Saving..." : "Save"}
</button>
</div>
</div>

View File

@ -1,7 +1,6 @@
"use client";
import { useState } from "react";
import { useRouter, useParams } from "next/navigation";
import { LoadingOverlay } from "@/components/foundation/loading/loading-overlay";
import { PlusIcon, ChevronDownIcon, ChevronUpIcon, PencilIcon, TrashIcon } from "lucide-react";
@ -21,16 +20,16 @@ interface EnvGroupProps {
function EnvGroup({ title, isOpen, onToggle, children, varCount }: EnvGroupProps) {
return (
<div className="border-b border-gray-800 last:border-b-0">
<div className="border-b border-border last:border-b-0">
<div
className="flex items-center justify-between py-4 cursor-pointer"
onClick={onToggle}
>
<div className="flex items-center space-x-2">
<h3 className="text-lg font-medium">{title}</h3>
<span className="text-sm text-gray-400">({varCount})</span>
<h3 className="text-lg font-medium text-foreground">{title}</h3>
<span className="text-sm text-muted-foreground">({varCount})</span>
</div>
<button className="p-1">
<button className="p-1 text-foreground hover:text-accent-foreground">
{isOpen ? <ChevronUpIcon size={18} /> : <ChevronDownIcon size={18} />}
</button>
</div>
@ -44,9 +43,6 @@ function EnvGroup({ title, isOpen, onToggle, children, varCount }: EnvGroupProps
}
export default function EnvVarsPage() {
const params = useParams();
const { provider, id } = params;
const [isAddingVar, setIsAddingVar] = useState(false);
const [newVarKey, setNewVarKey] = useState("");
const [newVarValue, setNewVarValue] = useState("");
@ -175,7 +171,7 @@ export default function EnvVarsPage() {
return (
<div key={index} className="flex items-center space-x-2 mb-2">
<input
className="flex-1 px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
className="flex-1 px-3 py-2 rounded-md bg-background border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
value={variable.key}
onChange={(e) => {
const updatedVars = env === 'production'
@ -191,7 +187,7 @@ export default function EnvVarsPage() {
placeholder="KEY"
/>
<input
className="flex-1 px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
className="flex-1 px-3 py-2 rounded-md bg-background border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
value={variable.value}
onChange={(e) => {
const updatedVars = env === 'production'
@ -207,13 +203,13 @@ export default function EnvVarsPage() {
placeholder="Value"
/>
<button
className="p-2 hover:bg-gray-800 rounded-md"
className="p-2 hover:bg-accent hover:text-accent-foreground rounded-md text-foreground transition-colors"
onClick={() => updateVariable(env, index, variable.key, variable.value)}
>
Save
</button>
<button
className="p-2 hover:bg-gray-800 rounded-md"
className="p-2 hover:bg-accent hover:text-accent-foreground rounded-md text-foreground transition-colors"
onClick={() => {
const updatedVars = env === 'production'
? [...productionVars]
@ -234,19 +230,19 @@ export default function EnvVarsPage() {
return (
<div key={index} className="flex items-center mb-2">
<div className="flex-1 flex items-center justify-between px-3 py-2 rounded-md bg-gray-900 border border-gray-700 mr-2">
<span>{variable.key}</span>
<span>{variable.value}</span>
<div className="flex-1 flex items-center justify-between px-3 py-2 rounded-md bg-muted border border-border mr-2">
<span className="text-foreground">{variable.key}</span>
<span className="text-foreground">{variable.value}</span>
</div>
<div className="flex space-x-1">
<button
className="p-2 hover:bg-gray-800 rounded-md"
className="p-2 hover:bg-accent hover:text-accent-foreground rounded-md text-muted-foreground transition-colors"
onClick={() => editVariable(env, index)}
>
<PencilIcon size={16} />
</button>
<button
className="p-2 hover:bg-gray-800 rounded-md"
className="p-2 hover:bg-accent hover:text-accent-foreground rounded-md text-muted-foreground transition-colors"
onClick={() => removeVariable(env, index)}
>
<TrashIcon size={16} />
@ -261,45 +257,45 @@ export default function EnvVarsPage() {
{isSaving && <LoadingOverlay />}
<div className="space-y-6 w-full">
<div className="rounded-lg border border-gray-800 p-6 bg-black">
<h2 className="text-xl font-semibold mb-4">Environment Variables</h2>
<p className="text-sm text-gray-400 mb-6">
<div className="rounded-lg border border-border p-6 bg-card">
<h2 className="text-xl font-semibold mb-4 text-foreground">Environment Variables</h2>
<p className="text-sm text-muted-foreground mb-6">
A new deployment is required for your changes to take effect.
</p>
{!isAddingVar ? (
<button
className="flex items-center space-x-2 px-4 py-2 rounded-md border border-gray-700 bg-gray-900 hover:bg-gray-800 transition-colors"
className="flex items-center space-x-2 px-4 py-2 rounded-md border border-border bg-background hover:bg-accent hover:text-accent-foreground transition-colors text-foreground"
onClick={() => setIsAddingVar(true)}
>
<PlusIcon size={16} />
<span>Create new variable</span>
</button>
) : (
<div className="space-y-4 mb-6 border border-gray-800 p-4 rounded-md">
<div className="space-y-4 mb-6 border border-border p-4 rounded-md bg-background">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Key</label>
<label className="block text-sm font-medium mb-1 text-foreground">Key</label>
<input
value={newVarKey}
onChange={(e) => setNewVarKey(e.target.value)}
className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
className="w-full px-3 py-2 rounded-md bg-background border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="KEY"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Value</label>
<label className="block text-sm font-medium mb-1 text-foreground">Value</label>
<input
value={newVarValue}
onChange={(e) => setNewVarValue(e.target.value)}
className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
className="w-full px-3 py-2 rounded-md bg-background border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Value"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">Environments</label>
<label className="block text-sm font-medium mb-2 text-foreground">Environments</label>
<div className="space-y-2">
<div className="flex items-center">
<input
@ -307,9 +303,9 @@ export default function EnvVarsPage() {
id="env-production"
checked={envSelection.production}
onChange={() => handleEnvSelectionChange('production')}
className="mr-2"
className="mr-2 rounded border-border"
/>
<label htmlFor="env-production">Production</label>
<label htmlFor="env-production" className="text-foreground">Production</label>
</div>
<div className="flex items-center">
<input
@ -317,9 +313,9 @@ export default function EnvVarsPage() {
id="env-preview"
checked={envSelection.preview}
onChange={() => handleEnvSelectionChange('preview')}
className="mr-2"
className="mr-2 rounded border-border"
/>
<label htmlFor="env-preview">Preview</label>
<label htmlFor="env-preview" className="text-foreground">Preview</label>
</div>
<div className="flex items-center">
<input
@ -327,22 +323,22 @@ export default function EnvVarsPage() {
id="env-development"
checked={envSelection.development}
onChange={() => handleEnvSelectionChange('development')}
className="mr-2"
className="mr-2 rounded border-border"
/>
<label htmlFor="env-development">Development</label>
<label htmlFor="env-development" className="text-foreground">Development</label>
</div>
</div>
</div>
<div className="flex justify-end space-x-2">
<button
className="px-4 py-2 rounded-md border border-gray-700 hover:bg-gray-800 transition-colors"
className="px-4 py-2 rounded-md border border-border hover:bg-accent hover:text-accent-foreground transition-colors bg-background text-foreground"
onClick={cancelAddVariable}
>
Cancel
</button>
<button
className="px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-700 transition-colors"
className="px-4 py-2 rounded-md bg-primary hover:bg-primary/90 transition-colors text-primary-foreground"
onClick={addVariable}
disabled={!newVarKey.trim() || !newVarValue.trim()}
>
@ -364,7 +360,7 @@ export default function EnvVarsPage() {
{productionVars.map((variable, index) => renderEnvVarRow('production', variable, index))}
</div>
) : (
<p className="text-sm text-gray-400">No variables defined</p>
<p className="text-sm text-muted-foreground">No variables defined</p>
)}
</EnvGroup>
@ -379,7 +375,7 @@ export default function EnvVarsPage() {
{previewVars.map((variable, index) => renderEnvVarRow('preview', variable, index))}
</div>
) : (
<p className="text-sm text-gray-400">No variables defined</p>
<p className="text-sm text-muted-foreground">No variables defined</p>
)}
</EnvGroup>
@ -394,18 +390,18 @@ export default function EnvVarsPage() {
{deploymentVars.map((variable, index) => renderEnvVarRow('development', variable, index))}
</div>
) : (
<p className="text-sm text-gray-400">No variables defined</p>
<p className="text-sm text-muted-foreground">No variables defined</p>
)}
</EnvGroup>
</div>
<div className="mt-6">
<button
className="px-4 py-2 rounded-md bg-gray-800 hover:bg-gray-700 transition-colors"
className="px-4 py-2 rounded-md bg-accent hover:bg-accent/90 transition-colors text-accent-foreground"
onClick={saveChanges}
disabled={isSaving}
>
Save changes
{isSaving ? "Saving..." : "Save changes"}
</button>
</div>
</div>

View File

@ -1,47 +1,26 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import { useSearchParams, useRouter } from "next/navigation";
import { Clipboard } from "lucide-react";
import { Dropdown } from "@/components/core/dropdown";
import { LoadingOverlay } from "@/components/foundation/loading/loading-overlay";
import { useRepoData } from "@/hooks/useRepoData";
import { useGQLClient } from "@/context";
import type { Project } from '@workspace/gql-client';
import { Button } from '@workspace/ui/components/button';
import { Input } from '@workspace/ui/components/input';
import { Label } from '@workspace/ui/components/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@workspace/ui/components/dialog';
// Create a simple modal component
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
footer?: React.ReactNode;
interface ProjectSettingsPageProps {
project?: Project;
onProjectUpdated?: () => void;
}
function Modal({ isOpen, onClose, title, children, footer }: ModalProps) {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
<div className="bg-gray-900 border border-gray-700 rounded-lg w-full max-w-md p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium">{title}</h3>
<button onClick={onClose} className="text-gray-400 hover:text-white">
&times;
</button>
</div>
<div className="mb-6">{children}</div>
{footer && <div className="flex justify-end">{footer}</div>}
</div>
</div>
);
}
export default function ProjectSettingsPage() {
export default function ProjectSettingsPage({ project, onProjectUpdated }: ProjectSettingsPageProps) {
const client = useGQLClient();
const router = useRouter();
const params = useParams();
const id = params?.id ? String(params.id) : '';
// Use the hook to get repo data
const { repoData, isLoading } = useRepoData(id);
const searchParams = useSearchParams();
const [projectName, setProjectName] = useState("");
const [projectDescription, setProjectDescription] = useState("");
@ -51,70 +30,111 @@ export default function ProjectSettingsPage() {
const [isTransferring, setIsTransferring] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [successMessage, setSuccessMessage] = useState("");
const [errorMessage, setErrorMessage] = useState("");
// Update form values when project data is loaded
useEffect(() => {
if (repoData) {
setProjectName(repoData.name || "");
setProjectDescription(repoData.description || "");
setProjectId(repoData.id?.toString() || "");
if (project) {
setProjectName(project.name || "");
setProjectDescription(project.description || "");
setProjectId(project.id || "");
}
}, [repoData]);
}, [project]);
// Check for delete action in URL params
useEffect(() => {
const action = searchParams?.get('action');
if (action === 'delete' && project) {
setIsDeleteModalOpen(true);
// Clean up the URL by removing the query parameter
const url = new URL(window.location.href);
url.searchParams.delete('action');
router.replace(url.pathname);
}
}, [searchParams, project, router]);
const accountOptions = [
{ label: "Personal Account", value: "account1" },
{ label: "Team Account", value: "account2" }
];
const showMessage = (message: string, isError = false) => {
if (isError) {
setErrorMessage(message);
setSuccessMessage("");
} else {
setSuccessMessage(message);
setErrorMessage("");
}
setTimeout(() => {
setSuccessMessage("");
setErrorMessage("");
}, 3000);
};
const handleSave = async () => {
if (!project) return;
try {
setIsSaving(true);
console.log("Saving project info:", { projectName, projectDescription });
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
await client.updateProject(project.id, {
name: projectName,
description: projectDescription
});
// Show success notification - in a real app you'd use a toast library
console.log("Project updated successfully");
showMessage("Project updated successfully");
if (onProjectUpdated) {
onProjectUpdated();
}
} catch (error) {
console.error("Failed to save project info:", error);
// Show error notification
showMessage("Failed to update project", true);
} finally {
setIsSaving(false);
}
};
const handleTransfer = async () => {
if (!project) return;
try {
setIsTransferring(true);
// Transfer project to selected account
// TODO: Implement actual transfer API call when available
console.log("Transferring project to:", selectedAccount);
// Implement API call to transfer project
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call
// After successful transfer, navigate back to projects list
router.push("/dashboard/projects");
showMessage("Project transfer initiated successfully");
// Note: No navigation - staying in the same tab
} catch (error) {
console.error("Failed to transfer project:", error);
// Show error notification
showMessage("Failed to transfer project", true);
} finally {
setIsTransferring(false);
}
};
const handleDelete = async () => {
if (!project) return;
try {
setIsDeleting(true);
// Delete project
console.log("Deleting project");
// Implement API call to delete project
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call
// After successful deletion, navigate back to projects list
router.push("/dashboard/projects");
await client.deleteProject(project.id);
showMessage("Project deleted successfully");
setIsDeleteModalOpen(false);
// Navigate back to projects list after successful deletion
setTimeout(() => {
router.push('/projects');
}, 1500);
} catch (error) {
console.error("Failed to delete project:", error);
// Show error notification
showMessage("Failed to delete project", true);
} finally {
setIsDeleting(false);
setIsDeleteModalOpen(false);
@ -123,29 +143,15 @@ export default function ProjectSettingsPage() {
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
showMessage("Project ID copied to clipboard");
};
const DeleteModalFooter = (
<div className="flex space-x-2">
<button
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-md text-white"
onClick={() => setIsDeleteModalOpen(false)}
disabled={isDeleting}
>
Cancel
</button>
<button
className="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-md text-white"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
</button>
</div>
);
if (isLoading) {
return <LoadingOverlay />;
if (!project) {
return (
<div className="p-6 text-center text-muted-foreground">
No project data available
</div>
);
}
return (
@ -153,47 +159,61 @@ export default function ProjectSettingsPage() {
{(isSaving || isTransferring || isDeleting) && <LoadingOverlay />}
<div className="space-y-8 w-full">
<div className="rounded-lg border border-gray-800 p-6 bg-black">
<h2 className="text-xl font-semibold mb-4">Project Info</h2>
{/* Success/Error Messages */}
{successMessage && (
<div className="bg-green-500/10 text-green-500 p-3 rounded-md border border-green-500/20">
{successMessage}
</div>
)}
{errorMessage && (
<div className="bg-destructive/10 text-destructive p-3 rounded-md border border-destructive/20">
{errorMessage}
</div>
)}
{/* Project Info Section */}
<div className="rounded-lg border border-border p-6 bg-card">
<h2 className="text-xl font-semibold mb-4 text-card-foreground">Project Info</h2>
<div className="space-y-4">
<div>
<label htmlFor="appName" className="block text-sm font-medium mb-1">
<Label htmlFor="appName" className="text-card-foreground">
App name
</label>
<input
</Label>
<Input
id="appName"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
className="mt-1"
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium mb-1">
<Label htmlFor="description" className="text-card-foreground">
Description
</label>
<input
</Label>
<Input
id="description"
value={projectDescription}
onChange={(e) => setProjectDescription(e.target.value)}
className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
placeholder="Enter project description"
className="mt-1"
/>
</div>
<div>
<label htmlFor="projectId" className="block text-sm font-medium mb-1">
<Label htmlFor="projectId" className="text-card-foreground">
Project ID
</label>
<div className="relative">
<input
</Label>
<div className="relative mt-1">
<Input
id="projectId"
value={projectId}
readOnly
className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white pr-10"
className="pr-10 bg-muted"
/>
<button
className="absolute right-2 top-1/2 transform -translate-y-1/2"
className="absolute right-2 top-1/2 transform -translate-y-1/2 hover:text-foreground text-muted-foreground transition-colors"
onClick={() => copyToClipboard(projectId)}
aria-label="Copy project ID"
>
@ -202,73 +222,132 @@ export default function ProjectSettingsPage() {
</div>
</div>
<button
className="px-4 py-2 border border-gray-600 rounded-md hover:bg-gray-800 transition-colors mt-2"
<div>
<Label className="text-card-foreground">Repository</Label>
<Input
value={project.repository || 'No repository linked'}
readOnly
className="mt-1 bg-muted"
/>
</div>
<div>
<Label className="text-card-foreground">Framework</Label>
<Input
value={project.framework || 'Unknown'}
readOnly
className="mt-1 bg-muted"
/>
</div>
<div>
<Label className="text-card-foreground">Production Branch</Label>
<Input
value={project.prodBranch || 'main'}
readOnly
className="mt-1 bg-muted"
/>
</div>
<div>
<Label className="text-card-foreground">Organization</Label>
<Input
value={project.organization?.name || 'Unknown'}
readOnly
className="mt-1 bg-muted"
/>
</div>
<Button
onClick={handleSave}
disabled={isSaving}
className="mt-4"
>
{isSaving ? "Saving..." : "Save"}
</button>
</Button>
</div>
</div>
<div className="rounded-lg border border-gray-800 p-6 bg-black">
<h2 className="text-xl font-semibold mb-4">Transfer Project</h2>
{/* Transfer Project Section */}
<div className="rounded-lg border border-border p-6 bg-card">
<h2 className="text-xl font-semibold mb-4 text-card-foreground">Transfer Project</h2>
<div>
<label htmlFor="account" className="block text-sm font-medium mb-1">
Select account
</label>
<Dropdown
label="Select"
options={accountOptions}
selectedValue={selectedAccount}
onSelect={(value) => setSelectedAccount(value)}
className="w-full"
/>
<div className="space-y-4">
<div>
<Label htmlFor="account" className="text-card-foreground">
Select account
</Label>
<Dropdown
label="Select"
options={accountOptions}
selectedValue={selectedAccount}
onSelect={(value) => setSelectedAccount(value)}
className="w-full mt-1"
/>
</div>
<p className="text-sm text-gray-400 mt-2">
<p className="text-sm text-muted-foreground">
Transfer this app to your personal account or a team you are a member of.
</p>
<button
className="px-4 py-2 border border-gray-600 rounded-md hover:bg-gray-800 transition-colors mt-4"
<Button
variant="outline"
onClick={handleTransfer}
disabled={!selectedAccount || isTransferring}
>
{isTransferring ? "Transferring..." : "Transfer"}
</button>
</Button>
</div>
</div>
<div className="rounded-lg border border-gray-800 border-red-900 p-6 bg-black">
<h2 className="text-xl font-semibold mb-4 text-red-500">Delete Project</h2>
{/* Delete Project Section */}
<div className="rounded-lg border border-destructive/50 p-6 bg-card">
<h2 className="text-xl font-semibold mb-4 text-destructive">Delete Project</h2>
<p className="text-sm text-gray-400 mb-4">
<p className="text-sm text-muted-foreground mb-4">
The project will be permanently deleted, including its deployments and domains. This action is
irreversible and cannot be undone.
</p>
<button
className="px-4 py-2 bg-red-700 hover:bg-red-800 rounded-md text-white transition-colors"
<Button
variant="destructive"
onClick={() => setIsDeleteModalOpen(true)}
>
Delete project
</button>
<Modal
isOpen={isDeleteModalOpen}
onClose={() => !isDeleting && setIsDeleteModalOpen(false)}
title="Are you absolutely sure?"
footer={DeleteModalFooter}
>
<p className="text-gray-300">
This action cannot be undone. This will permanently delete the project
and all associated deployments and domains.
</p>
</Modal>
</Button>
</div>
</div>
{/* Delete Confirmation Modal */}
<Dialog open={isDeleteModalOpen} onOpenChange={(open) => !isDeleting && setIsDeleteModalOpen(open)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you absolutely sure?</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-foreground">
This action cannot be undone. This will permanently delete the project{" "}
<strong>"{project.name}"</strong> and all associated deployments and domains.
</p>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDeleteModalOpen(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -1,21 +1,56 @@
'use client';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { PageWrapper } from "@/components/foundation";
import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import ProjectSettingsPage from "./ProjectSettingsPage";
import { useRouter } from 'next/navigation';
import { useRepoData } from '@/hooks/useRepoData';
import { useGQLClient } from '@/context';
import type { Project } from '@workspace/gql-client';
export default function SettingsPage() {
const router = useRouter();
const params = useParams();
const client = useGQLClient();
// Safely unwrap params
const id = params?.id ? String(params.id) : '';
const provider = params?.provider ? String(params.provider) : '';
// Use the hook to get repo data
const { repoData } = useRepoData(id);
// State for project data
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Load project data using GraphQL client
useEffect(() => {
if (id) {
loadProject(id);
}
}, [id]);
const loadProject = async (projectId: string) => {
try {
setLoading(true);
setError(null);
const response = await client.getProject(projectId);
setProject(response.project);
} catch (err) {
console.error('Failed to load project:', err);
setError(err instanceof Error ? err.message : 'Failed to load project');
} finally {
setLoading(false);
}
};
// Handle project updates
const handleProjectUpdated = () => {
if (id) {
loadProject(id);
}
};
// Handle tab changes by navigating to the correct folder
const handleTabChange = (value: string) => {
@ -39,21 +74,60 @@ export default function SettingsPage() {
break;
}
};
// Show loading state
if (loading) {
return (
<PageWrapper
header={{
title: 'Loading...',
actions: []
}}
layout="bento"
className="pb-0"
>
<div className="md:col-span-3 w-full flex items-center justify-center py-12">
<div className="text-muted-foreground">Loading project settings...</div>
</div>
</PageWrapper>
);
}
// Show error state
if (error || !project) {
return (
<PageWrapper
header={{
title: 'Error',
actions: []
}}
layout="bento"
className="pb-0"
>
<div className="md:col-span-3 w-full flex items-center justify-center py-12">
<div className="text-center">
<div className="text-destructive mb-2">Failed to load project</div>
<div className="text-muted-foreground text-sm">{error}</div>
</div>
</div>
</PageWrapper>
);
}
return (
<PageWrapper
header={{
title: repoData ? `${repoData.name}` : 'Project Settings',
title: project.name || 'Project Settings',
actions: [
{
label: 'Open repo',
href: repoData?.html_url || '#',
href: project.repository || '#',
icon: 'external-link',
external: true
},
{
label: 'View app',
href: repoData ? `https://${repoData.name.toLowerCase()}.example.com` : '#',
href: project.deployments?.[0]?.applicationDeploymentRecordData?.url || '#',
icon: 'external-link',
external: true
}
@ -74,9 +148,12 @@ export default function SettingsPage() {
</TabsList>
</Tabs>
{/* Settings content */}
{/* Settings content - now with proper project data */}
<div className="mt-6">
<ProjectSettingsPage />
<ProjectSettingsPage
project={project}
onProjectUpdated={handleProjectUpdated}
/>
</div>
</div>
</PageWrapper>

View File

@ -9,119 +9,325 @@ import {
AvatarFallback} from '@workspace/ui/components/avatar';
import { Button } from '@workspace/ui/components/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { Activity, Clock, GitBranch, ExternalLink } from 'lucide-react';
import { Activity, Clock, GitBranch, ExternalLink, AlertCircle, Square, Search, Calendar, ChevronDown, Clipboard } from 'lucide-react';
import { Badge } from '@workspace/ui/components/badge';
import { Input } from '@workspace/ui/components/input';
import { Label } from '@workspace/ui/components/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@workspace/ui/components/dialog';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useRepoData } from '@/hooks/useRepoData';
import { useGQLClient } from '@/context';
import { useEffect, useState } from 'react';
import type { Project } from '@workspace/gql-client';
// Import the tab content components
import GitPage from './(integrations)/int/GitPage';
import EnvVarsPage from './(settings)/set/(environment-variables)/env/EnvVarsPage';
export default function ProjectOverviewPage() {
const router = useRouter();
const params = useParams();
const client = useGQLClient();
// Safely unwrap params
const id = params?.id ? String(params.id) : '';
const provider = params?.provider ? String(params.provider) : '';
// Use the hook to get repo data
const { repoData } = useRepoData(id);
// State management
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState('overview'); // Local tab state
// Default deployment details
const [deploymentUrl, setDeploymentUrl] = useState('');
const [deploymentDate, setDeploymentDate] = useState(Date.now() - 60 * 60 * 1000); // 1 hour ago
const [deployedBy, setDeployedBy] = useState('');
const [projectName, setProjectName] = useState('');
const [branch, setBranch] = useState('main');
// Update details when repo data is loaded
// Deployment page state
const [deployments, setDeployments] = useState<any[]>([]);
const [filteredDeployments, setFilteredDeployments] = useState<any[]>([]);
const [isLogsOpen, setIsLogsOpen] = useState(false);
const [deploymentLogs, setDeploymentLogs] = useState<string>('');
// Load project data
useEffect(() => {
if (repoData) {
setProjectName(repoData.name);
setBranch(repoData.default_branch || 'main');
setDeployedBy(repoData.owner?.login || 'username');
// Create a deployment URL based on the repo name
setDeploymentUrl(`https://${repoData.name.toLowerCase()}.example.com`);
if (id) {
loadProject(id);
}
}, [repoData]);
// Auction data
const auctionId = 'laconic1sdfjwei4jfkasifgjiai45ioasjf5jjjafij355';
}, [id]);
// Activities data
const activities = [
{
username: deployedBy || 'username',
branch: branch,
action: 'deploy: source cargo',
time: '5 minutes ago'
},
{
username: deployedBy || 'username',
branch: branch,
action: 'bump',
time: '5 minutes ago'
},
{
username: deployedBy || 'username',
branch: branch,
action: 'version: update version',
time: '5 minutes ago'
},
{
username: deployedBy || 'username',
branch: branch,
action: 'build: updates',
time: '5 minutes ago'
}
];
// Handle tab changes by navigating to the correct folder
const handleTabChange = (value: string) => {
const basePath = `/projects/${provider}/ps/${id}`;
switch (value) {
case 'overview':
router.push(basePath);
break;
case 'deployment':
router.push(`${basePath}/dep`);
break;
case 'settings':
router.push(`${basePath}/set`);
break;
case 'git':
router.push(`${basePath}/int`);
break;
case 'env-vars':
router.push(`${basePath}/set/env`);
break;
// Load project from GraphQL
const loadProject = async (projectId: string) => {
try {
setLoading(true);
setError(null);
const response = await client.getProject(projectId);
setProject(response.project);
// Set deployments for the deployment tab
if (response.project?.deployments) {
setDeployments(response.project.deployments);
setFilteredDeployments(response.project.deployments);
}
} catch (err) {
console.error('Failed to load project:', err);
setError(err instanceof Error ? err.message : 'Failed to load project');
} finally {
setLoading(false);
}
};
// Refresh project data
const handleRefresh = () => {
if (id) {
loadProject(id);
}
};
const currentDeployment = project?.deployments?.find((d: any) => d.isCurrent);
const latestDeployment = project?.deployments?.[0]; // Assuming deployments are sorted by date
// Handle tab changes WITHOUT navigation - just update local state
const handleTabChange = (value: string) => {
setActiveTab(value);
};
// Helper function to safely parse dates
const parseDate = (dateString: string | undefined) => {
if (!dateString) return null;
const date = new Date(dateString);
return isNaN(date.getTime()) ? null : date.getTime();
};
// Generate activities from deployments
const generateActivities = () => {
if (!project?.deployments) return [];
return project.deployments
.slice(0, 4) // Show last 4 deployments
.map((deployment: any) => ({
username: deployment.createdBy?.name || 'Unknown',
branch: deployment.branch,
action: `deployed ${deployment.environment || 'production'}`,
time: parseDate(deployment.createdAt) ? relativeTimeMs(parseDate(deployment.createdAt)!) : 'Unknown time',
status: deployment.status
}));
};
const activities = generateActivities();
// Status badge component
const StatusBadge = ({ status }: { status: string }) => {
const getStatusColor = (status: string) => {
switch (status?.toUpperCase()) {
case 'COMPLETED':
case 'READY':
return 'bg-green-700/20 text-green-400';
case 'BUILDING':
case 'DEPLOYING':
return 'bg-blue-700/20 text-blue-400';
case 'ERROR':
case 'FAILED':
return 'bg-red-700/20 text-red-400';
default:
return 'bg-gray-700/20 text-gray-400';
}
};
return (
<div className={`inline-block px-2 py-0.5 text-xs font-medium rounded ${getStatusColor(status)}`}>
{status?.toUpperCase() || 'UNKNOWN'}
</div>
);
};
// Handle deployment logs
const handleViewLogs = () => {
const mockLogs = `[2025-02-12 10:03:12] INFO Starting deployment process for service: ${project?.name}
[2025-02-12 10:03:14] INFO Fetching latest commit from main branch (commit: a1b2c3d)
[2025-02-12 10:03:15] INFO Building Docker image: registry.company.com/${project?.name}:latest
[2025-02-12 10:03:26] INFO Running security scan on built image
[2025-02-12 10:03:30] INFO Pushing image to container registry
[2025-02-12 10:03:35] INFO Updating deployment configuration
[2025-02-12 10:03:40] INFO Scaling down old pods
[2025-02-12 10:03:42] INFO Scaling up new pods
[2025-02-12 10:03:50] INFO Running health checks on new pods
[2025-02-12 10:03:55] INFO Deployment completed successfully
[2025-02-12 10:03:56] INFO Service is now live at ${currentDeployment?.applicationDeploymentRecordData?.url}`;
setDeploymentLogs(mockLogs);
setIsLogsOpen(true);
};
// Handle deployment deletion
const handleDeleteDeployment = async (deploymentId: string, deploymentBranch: string) => {
if (!confirm(`Are you sure you want to delete the deployment for branch "${deploymentBranch}"? This action cannot be undone.`)) {
return;
}
try {
setLoading(true);
await client.deleteDeployment(deploymentId);
// Refresh the project data to update the deployments list
await loadProject(id);
// Show success message (you could replace with a toast notification)
alert('Deployment deleted successfully');
} catch (error) {
console.error('Failed to delete deployment:', error);
alert('Failed to delete deployment. Please try again.');
} finally {
setLoading(false);
}
};
// Handle deployment rollback
const handleRollbackDeployment = async (deploymentId: string, deploymentBranch: string) => {
if (!project?.id) return;
if (!confirm(`Are you sure you want to rollback to the deployment for branch "${deploymentBranch}"?`)) {
return;
}
try {
setLoading(true);
await client.rollbackDeployment(project.id, deploymentId);
// Refresh the project data to update the deployments list
await loadProject(id);
// Show success message
alert('Deployment rollback completed successfully');
} catch (error) {
console.error('Failed to rollback deployment:', error);
alert('Failed to rollback deployment. Please try again.');
} finally {
setLoading(false);
}
};
// Copy to clipboard function for settings
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
// You could add a toast notification here
};
// Settings page state
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [successMessage, setSuccessMessage] = useState("");
const [errorMessage, setErrorMessage] = useState("");
const showMessage = (message: string, isError = false) => {
if (isError) {
setErrorMessage(message);
setSuccessMessage("");
} else {
setSuccessMessage(message);
setErrorMessage("");
}
setTimeout(() => {
setSuccessMessage("");
setErrorMessage("");
}, 3000);
};
// Handle project deletion
const handleDeleteProject = async () => {
if (!project) return;
try {
setIsDeleting(true);
await client.deleteProject(project.id);
showMessage("Project deleted successfully");
setIsDeleteModalOpen(false);
// Navigate back to projects list after successful deletion
setTimeout(() => {
router.push('/projects');
}, 1500);
} catch (error) {
console.error("Failed to delete project:", error);
showMessage("Failed to delete project", true);
} finally {
setIsDeleting(false);
setIsDeleteModalOpen(false);
}
};
// Loading state
if (loading) {
return (
<PageWrapper
header={{
title: 'Loading...',
actions: []
}}
layout="bento"
className="pb-0"
>
<div className="md:col-span-3 w-full flex items-center justify-center py-12">
<div className="text-muted-foreground">Loading project data...</div>
</div>
</PageWrapper>
);
}
// Error state
if (error || !project) {
return (
<PageWrapper
header={{
title: 'Project Not Found',
actions: []
}}
layout="bento"
className="pb-0"
>
<div className="md:col-span-3 w-full flex flex-col items-center justify-center py-12">
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
<div className="text-xl font-medium mb-2">Project not found</div>
<div className="text-muted-foreground mb-4">
{error ? `Error: ${error}` : 'The requested project could not be loaded.'}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => router.back()}>
Go Back
</Button>
<Button onClick={handleRefresh}>
Retry
</Button>
</div>
</div>
</PageWrapper>
);
}
return (
<PageWrapper
header={{
title: projectName || 'Project Overview',
title: project.name || 'Project Overview',
actions: [
{
label: 'Open repo',
href: repoData?.html_url || '#',
href: project.repository || '#',
icon: 'external-link',
external: true
},
{
label: 'View app',
href: deploymentUrl || '#',
href: currentDeployment?.applicationDeploymentRecordData?.url || latestDeployment?.applicationDeploymentRecordData?.url || '#',
icon: 'external-link',
external: true
}
]
}}
layout="bento" // Use bento layout to override max width
layout="bento"
className="pb-0"
>
<div className="md:col-span-3 w-full"> {/* Take full width in bento grid */}
{/* Tabs navigation */}
<Tabs defaultValue="overview" className="w-full" onValueChange={handleTabChange}>
<div className="md:col-span-3 w-full">
{/* Tabs navigation - controlled locally */}
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="deployment">Deployment</TabsTrigger>
@ -137,13 +343,23 @@ export default function ProjectOverviewPage() {
<div className="p-6">
<div className="flex items-center">
<Avatar className="h-10 w-10 mr-4 bg-blue-600">
<AvatarFallback>{getInitials(projectName || '')}</AvatarFallback>
<AvatarFallback>{getInitials(project.name || '')}</AvatarFallback>
</Avatar>
<div>
<h2 className="text-lg font-medium">{projectName}</h2>
<div className="flex-1">
<div className="flex items-center gap-3">
<h2 className="text-lg font-medium">{project.name}</h2>
{currentDeployment && (
<StatusBadge status={currentDeployment.status} />
)}
</div>
<p className="text-muted-foreground">
{deploymentUrl.replace(/^https?:\/\//, '')}
{currentDeployment?.applicationDeploymentRecordData?.url?.replace(/^https?:\/\//, '') ||
latestDeployment?.applicationDeploymentRecordData?.url?.replace(/^https?:\/\//, '') ||
'No deployment URL'}
</p>
{project.description && (
<p className="text-sm text-muted-foreground mt-1">{project.description}</p>
)}
</div>
</div>
@ -151,78 +367,120 @@ export default function ProjectOverviewPage() {
<div>
<div className="flex items-center mb-2">
<GitBranch className="h-4 w-4 mr-2 text-muted-foreground" />
<span className="text-muted-foreground text-sm">Source</span>
<span className="text-muted-foreground text-sm">Production Branch</span>
</div>
<div className="flex items-center">
<GitBranch className="h-4 w-4 mr-2" />
<span>{branch}</span>
<span>{project.prodBranch || 'main'}</span>
</div>
</div>
<div>
<div className="flex items-center mb-2">
<ExternalLink className="h-4 w-4 mr-2 text-muted-foreground" />
<span className="text-muted-foreground text-sm">Deployment URL</span>
<span className="text-muted-foreground text-sm">Repository</span>
</div>
<Link
href={deploymentUrl}
className="text-primary hover:underline flex items-center"
target="_blank"
>
{deploymentUrl}
</Link>
{project.repository ? (
<Link
href={project.repository}
className="text-primary hover:underline flex items-center"
target="_blank"
>
{project.repository.replace('https://github.com/', '')}
<ExternalLink className="h-3 w-3 ml-1" />
</Link>
) : (
<span className="text-muted-foreground">No repository linked</span>
)}
</div>
</div>
<div className="mt-6">
<div className="flex items-center mb-2">
<Clock className="h-4 w-4 mr-2 text-muted-foreground" />
<span className="text-muted-foreground text-sm">Deployment date</span>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
<div>
<div className="flex items-center mb-2">
<Clock className="h-4 w-4 mr-2 text-muted-foreground" />
<span className="text-muted-foreground text-sm">Last Deployment</span>
</div>
<div className="flex items-center">
<span className="mr-2">
{latestDeployment ?
(parseDate(latestDeployment.createdAt) ?
relativeTimeMs(parseDate(latestDeployment.createdAt)!) :
'Invalid date') :
'No deployments'
}
</span>
{latestDeployment?.createdBy && (
<>
<span className="mr-2">by</span>
<Avatar className="h-5 w-5 mr-2">
<AvatarFallback>{getInitials(latestDeployment.createdBy.name || '')}</AvatarFallback>
</Avatar>
<span>{latestDeployment.createdBy.name}</span>
</>
)}
</div>
</div>
<div className="flex items-center">
<span className="mr-2">
{relativeTimeMs(deploymentDate)}
</span>
<span className="mr-2">by</span>
<Avatar className="h-5 w-5 mr-2">
<AvatarFallback>{getInitials(deployedBy)}</AvatarFallback>
</Avatar>
<span>{deployedBy}</span>
<div>
<div className="flex items-center mb-2">
<Activity className="h-4 w-4 mr-2 text-muted-foreground" />
<span className="text-muted-foreground text-sm">Framework</span>
</div>
<div className="flex items-center">
<Badge variant="secondary">{project.framework || 'Unknown'}</Badge>
</div>
</div>
</div>
{/* Divider between project info and auction details */}
<div className="border-t border-border my-6"></div>
{/* Auction Details section */}
{/* Deployment Details section */}
<div>
<h3 className="text-lg font-medium mb-6">Auction Details</h3>
<h3 className="text-lg font-medium mb-6">Deployment Details</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 className="text-sm text-muted-foreground mb-1">Auction ID</h4>
<p className="text-sm font-medium break-all">{auctionId}</p>
<h4 className="text-sm text-muted-foreground mb-1">Project ID</h4>
<p className="text-sm font-medium font-mono break-all">{project.id}</p>
</div>
<div>
<h4 className="text-sm text-muted-foreground mb-1">Auction Status</h4>
<div className="inline-block px-2 py-0.5 bg-green-700/20 text-green-400 text-xs font-medium rounded">
COMPLETED
</div>
<h4 className="text-sm text-muted-foreground mb-1">Organization</h4>
<p className="text-sm font-medium">{project.organization?.name || 'Unknown'}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
<div>
<h4 className="text-sm text-muted-foreground mb-1">Deployer LRNs</h4>
<p className="text-sm font-medium break-all">{auctionId}</p>
</div>
<div>
<h4 className="text-sm text-muted-foreground mb-1">Deployer Funds Status</h4>
<div className="inline-block px-2 py-0.5 bg-blue-700/20 text-blue-400 text-xs font-medium rounded">
RELEASED
{project.auctionId && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
<div>
<h4 className="text-sm text-muted-foreground mb-1">Auction ID</h4>
<p className="text-sm font-medium font-mono break-all">{project.auctionId}</p>
</div>
<div>
<h4 className="text-sm text-muted-foreground mb-1">Funds Status</h4>
<StatusBadge status={project.fundsReleased ? 'RELEASED' : 'PENDING'} />
</div>
</div>
</div>
)}
{project.deployers && project.deployers.length > 0 && (
<div className="mt-6">
<h4 className="text-sm text-muted-foreground mb-3">Deployers ({project.deployers.length})</h4>
<div className="space-y-2">
{project.deployers.slice(0, 2).map((deployer: any, index: number) => (
<div key={index} className="text-sm font-mono bg-muted p-2 rounded">
{deployer.deployerLrn}
</div>
))}
{project.deployers.length > 2 && (
<div className="text-sm text-muted-foreground">
And {project.deployers.length - 2} more...
</div>
)}
</div>
</div>
)}
<div className="mt-6">
<Button variant="outline" size="sm">View details</Button>
@ -231,7 +489,7 @@ export default function ProjectOverviewPage() {
</div>
</div>
{/* Activity section - not in a card */}
{/* Activity section */}
<div className="mt-8">
<h3 className="text-lg font-medium mb-6 flex items-center">
<Activity className="mr-2 h-4 w-4" />
@ -239,29 +497,312 @@ export default function ProjectOverviewPage() {
</h3>
<div className="space-y-4">
{activities.map((activity, index) => (
<div key={index} className="flex items-start">
<div className="text-muted-foreground mr-2"></div>
<div className="flex-1">
<span className="text-sm mr-2">{activity.username}</span>
<GitBranch className="inline h-3 w-3 text-muted-foreground mx-1" />
<span className="text-sm text-muted-foreground mr-2">{activity.branch}</span>
<span className="text-sm text-muted-foreground">{activity.action}</span>
{activities.length > 0 ? (
activities.map((activity, index) => (
<div key={index} className="flex items-start">
<div className="text-muted-foreground mr-2"></div>
<div className="flex-1">
<span className="text-sm mr-2">{activity.username}</span>
<GitBranch className="inline h-3 w-3 text-muted-foreground mx-1" />
<span className="text-sm text-muted-foreground mr-2">{activity.branch}</span>
<span className="text-sm text-muted-foreground">{activity.action}</span>
</div>
<div className="text-sm text-muted-foreground">{activity.time}</div>
</div>
<div className="text-sm text-muted-foreground">{activity.time}</div>
))
) : (
<div className="text-muted-foreground text-center py-8">
No recent activity
</div>
))}
)}
</div>
</div>
</TabsContent>
{/* These content sections won't be shown - we'll navigate to respective pages instead */}
<TabsContent value="deployment"></TabsContent>
<TabsContent value="settings"></TabsContent>
<TabsContent value="git"></TabsContent>
<TabsContent value="env-vars"></TabsContent>
<TabsContent value="deployment" className="pt-6">
<div className="space-y-6">
{/* Filter Controls */}
<div className="flex flex-wrap gap-4">
<div className="relative flex-grow max-w-md">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-4 w-4 text-gray-400" />
</div>
<Input
type="text"
placeholder="Search branches"
className="pl-10"
disabled={deployments.length === 0}
/>
</div>
<Button
variant="outline"
className="flex items-center opacity-60"
disabled={deployments.length === 0}
>
<Calendar className="h-4 w-4 mr-2" />
<span>Select a date</span>
</Button>
<Button
variant="outline"
className="flex items-center opacity-60"
disabled={deployments.length === 0}
>
<span>All Status</span>
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
</div>
{/* Deployments List */}
{filteredDeployments.length > 0 ? (
filteredDeployments.map((deployment) => (
<div key={deployment.id} className="border border-border rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<GitBranch className="h-4 w-4" />
<span className="font-medium">{deployment.branch}</span>
<StatusBadge status={deployment.status} />
{deployment.isCurrent && (
<Badge variant="default">Current</Badge>
)}
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleViewLogs(deployment.id)}
>
View logs
</Button>
{!deployment.isCurrent && (
<Button
variant="outline"
size="sm"
onClick={() => handleRollbackDeployment(deployment.id, deployment.branch)}
>
Rollback
</Button>
)}
<Button
variant="destructive"
size="sm"
onClick={() => handleDeleteDeployment(deployment.id, deployment.branch)}
disabled={deployment.isCurrent}
>
Delete
</Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">URL: </span>
<Link
href={deployment.applicationDeploymentRecordData?.url || '#'}
className="text-primary hover:underline"
target="_blank"
>
{deployment.applicationDeploymentRecordData?.url}
</Link>
</div>
<div>
<span className="text-muted-foreground">Created: </span>
{parseDate(deployment.createdAt) ?
relativeTimeMs(parseDate(deployment.createdAt)!) :
'Unknown date'
}
</div>
<div>
<span className="text-muted-foreground">Commit: </span>
<span className="font-mono">{deployment.commitHash?.substring(0, 8) || 'Unknown'}</span>
</div>
<div>
<span className="text-muted-foreground">Created by: </span>
{deployment.createdBy?.name || 'Unknown'}
</div>
</div>
</div>
))
) : (
<div className="h-96 border border-gray-800 rounded-lg flex flex-col items-center justify-center gap-5 text-center">
<Square size={64} className="stroke-current" />
<h2 className="text-xl font-semibold mb-2">You have no deployments</h2>
<p className="text-gray-400 text-center max-w-md mb-6">
Deploy your first version to see deployment history here.
</p>
</div>
)}
</div>
</TabsContent>
<TabsContent value="settings" className="pt-6">
{/* Updated theme-aware settings content */}
<div className="space-y-8 w-full">
{/* Success/Error Messages */}
{successMessage && (
<div className="bg-green-500/10 text-green-500 p-3 rounded-md border border-green-500/20">
{successMessage}
</div>
)}
{errorMessage && (
<div className="bg-destructive/10 text-destructive p-3 rounded-md border border-destructive/20">
{errorMessage}
</div>
)}
<div className="rounded-lg border border-border p-6 bg-card">
<h2 className="text-xl font-semibold mb-4 text-card-foreground">Project Info</h2>
<div className="space-y-4">
<div>
<Label htmlFor="appName" className="text-card-foreground">
App name
</Label>
<Input
id="appName"
value={project.name || ''}
readOnly
className="mt-1 bg-muted"
/>
</div>
<div>
<Label htmlFor="description" className="text-card-foreground">
Description
</Label>
<Input
id="description"
value={project.description || ''}
readOnly
className="mt-1 bg-muted"
placeholder="No description"
/>
</div>
<div>
<Label htmlFor="projectId" className="text-card-foreground">
Project ID
</Label>
<div className="relative mt-1">
<Input
id="projectId"
value={project.id}
readOnly
className="pr-10 bg-muted"
/>
<button
className="absolute right-2 top-1/2 transform -translate-y-1/2 hover:text-foreground text-muted-foreground transition-colors"
onClick={() => copyToClipboard(project.id)}
aria-label="Copy project ID"
>
<Clipboard className="h-4 w-4" />
</button>
</div>
</div>
<div>
<Label className="text-card-foreground">Repository</Label>
<Input
value={project.repository || 'No repository linked'}
readOnly
className="mt-1 bg-muted"
/>
</div>
<div>
<Label className="text-card-foreground">Framework</Label>
<Input
value={project.framework || 'Unknown'}
readOnly
className="mt-1 bg-muted"
/>
</div>
<div>
<Label className="text-card-foreground">Production Branch</Label>
<Input
value={project.prodBranch || 'main'}
readOnly
className="mt-1 bg-muted"
/>
</div>
<div>
<Label className="text-card-foreground">Organization</Label>
<Input
value={project.organization?.name || 'Unknown'}
readOnly
className="mt-1 bg-muted"
/>
</div>
<Button variant="outline" disabled className="mt-4">
Edit Settings (Coming Soon)
</Button>
</div>
</div>
<div className="rounded-lg border border-destructive/50 p-6 bg-card">
<h2 className="text-xl font-semibold mb-4 text-destructive">Delete Project</h2>
<p className="text-sm text-muted-foreground mb-4">
The project will be permanently deleted, including its deployments and domains. This action is
irreversible and cannot be undone.
</p>
<Button
variant="destructive"
onClick={() => setIsDeleteModalOpen(true)}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete Project"}
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="git" className="pt-6">
<GitPage />
</TabsContent>
<TabsContent value="env-vars" className="pt-6">
<EnvVarsPage />
</TabsContent>
</Tabs>
</div>
{/* Delete Confirmation Modal */}
<Dialog open={isDeleteModalOpen} onOpenChange={(open) => !isDeleting && setIsDeleteModalOpen(open)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you absolutely sure?</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-foreground">
This action cannot be undone. This will permanently delete the project{" "}
<strong>"{project?.name}"</strong> and all associated deployments and domains.
</p>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDeleteModalOpen(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteProject}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageWrapper>
);
}

View File

@ -1,72 +1,85 @@
'use client'
import { PageWrapper } from '@/components/foundation'
import CheckBalanceIframe from '@/components/iframe/check-balance-iframe/CheckBalanceIframe'
import type { Project } from '@octokit/webhooks-types'
import { FixedProjectCard } from '@/components/projects/project/ProjectCard/FixedProjectCard'
import { Button } from '@workspace/ui/components/button'
import { useEffect, useState } from 'react'
import { Shapes } from 'lucide-react'
import { useAuth, useUser } from '@clerk/nextjs'
import { useRepoData } from '@/hooks/useRepoData'
import { useGQLClient } from '@/context'
import type { Project } from '@workspace/gql-client'
interface ProjectData {
id: string
name: string
icon?: string
deployments: any[]
// Additional fields from GitHub repo
full_name?: string
html_url?: string
updated_at?: string
default_branch?: string
repository?: string
framework?: string
description?: string
}
export default function ProjectsPage() {
const [, setIsBalanceSufficient] = useState<boolean>()
const [projects, setProjects] = useState<Project[]>([])
const [projects, setProjects] = useState<ProjectData[]>([])
const [isLoading, setIsLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
const { isLoaded: isAuthLoaded, userId } = useAuth()
const { isLoaded: isUserLoaded, user } = useUser()
const client = useGQLClient()
// Use the hook to fetch all repos (with an empty ID to get all)
const { repoData: allRepos, isLoading: reposLoading, error: reposError } = useRepoData('');
const handleConnectGitHub = () => {
window.open('https://accounts.clerk.dev/user', '_blank');
const handleCreateProject = () => {
window.location.href = '/projects/github/ps/cr'
}
useEffect(() => {
// Process repos data when it's loaded
if (!reposLoading && allRepos) {
// Transform GitHub repos to match ProjectData interface
const projectData: ProjectData[] = allRepos.map((repo: any) => ({
id: repo.id.toString(),
name: repo.name,
full_name: repo.full_name,
// Create a deployment object that matches your existing structure
deployments: [
{
applicationDeploymentRecordData: {
url: repo.html_url
},
branch: repo.default_branch,
createdAt: repo.updated_at,
createdBy: {
name: repo.owner?.login || 'Unknown'
}
}
]
}));
loadAllProjects()
}, [])
const loadAllProjects = async () => {
try {
setIsLoading(true)
setError(null)
setProjects(projectData);
setIsLoading(false);
} else if (!reposLoading && reposError) {
setError(reposError);
setIsLoading(false);
// First get organizations
const orgsResponse = await client.getOrganizations()
if (!orgsResponse.organizations || orgsResponse.organizations.length === 0) {
setProjects([])
setIsLoading(false)
return
}
// Get projects from all organizations
const allProjects: ProjectData[] = []
for (const org of orgsResponse.organizations) {
try {
const projectsResponse = await client.getProjectsInOrganization(org.slug)
// Transform GraphQL projects to match ProjectData interface
const orgProjects: ProjectData[] = projectsResponse.projectsInOrganization.map((project: Project) => ({
id: project.id,
name: project.name,
repository: project.repository,
framework: project.framework,
description: project.description,
deployments: project.deployments || []
}))
allProjects.push(...orgProjects)
} catch (orgError) {
console.error(`Failed to load projects for org ${org.slug}:`, orgError)
// Continue with other orgs even if one fails
}
}
setProjects(allProjects)
} catch (err) {
console.error('Failed to load projects:', err)
setError(err instanceof Error ? err.message : 'Failed to load projects')
} finally {
setIsLoading(false)
}
}, [allRepos, reposLoading, reposError]);
}
return (
<PageWrapper
@ -92,16 +105,13 @@ export default function ProjectsPage() {
</div>
<h2 className="text-xl font-semibold mb-2">Error: {error}</h2>
<p className="text-gray-400 text-center max-w-md mb-6">
Please connect your GitHub account to see your repositories.
Failed to load your deployed projects. Please try again.
</p>
<Button
className="bg-white text-black hover:bg-gray-200 flex items-center"
onClick={handleConnectGitHub}
onClick={loadAllProjects}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
Connect to GitHub
Try Again
</Button>
</div>
) : projects.length === 0 ? (
@ -114,29 +124,77 @@ export default function ProjectsPage() {
</div>
<h2 className="text-xl font-semibold mb-2">Deploy your first app</h2>
<p className="text-gray-400 text-center max-w-md mb-6">
Once connected, you can import a repository from your account or start with one of our templates.
You don't have any deployed projects yet. Create your first project to get started.
</p>
<Button
className="bg-white text-black hover:bg-gray-200 flex items-center"
onClick={handleConnectGitHub}
onClick={handleCreateProject}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Connect to GitHub
Create Project
</Button>
</div>
) : (
// Custom grid that spans the entire bento layout
<div className="md:col-span-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{projects.map((project) => (
<FixedProjectCard
project={project as any}
key={project.id}
status={project.deployments[0]?.branch ? 'success' : 'pending'}
/>
))}
{projects.map((project) => {
// Get the current deployment for status
const currentDeployment = project.deployments.find(d => d.isCurrent)
const latestDeployment = project.deployments[0] // Assuming sorted by date
// Determine status based on deployment
let status = 'pending'
if (currentDeployment || latestDeployment) {
const deployment = currentDeployment || latestDeployment
switch (deployment.status?.toUpperCase()) {
case 'READY':
case 'COMPLETED':
status = 'success'
break
case 'BUILDING':
case 'DEPLOYING':
status = 'in-progress'
break
case 'ERROR':
case 'FAILED':
status = 'failure'
break
default:
status = 'pending'
}
}
// Format the project data to match what FixedProjectCard expects
const formattedProject = {
id: project.id,
name: project.name,
full_name: project.repository ? project.repository.replace('https://github.com/', '') : project.name,
repository: project.repository,
framework: project.framework,
description: project.description,
// Ensure deployments array is properly formatted
deployments: project.deployments.map(deployment => ({
...deployment,
// Make sure the date is in a format the card can parse
createdAt: deployment.createdAt,
applicationDeploymentRecordData: {
url: deployment.applicationDeploymentRecordData?.url || `https://${project.name.toLowerCase()}.example.com`
}
}))
}
return (
<FixedProjectCard
project={formattedProject}
key={project.id}
status={status as any}
/>
)
})}
</div>
</div>
)}

View File

@ -0,0 +1,166 @@
// src/app/auth/github/backend-callback/page.tsx
'use client'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@workspace/ui/components/card'
import { CheckCircle2, AlertCircle, Loader2 } from 'lucide-react'
import { useGQLClient } from '@/context'
export default function GitHubBackendCallbackPage() {
const searchParams = useSearchParams()
const [status, setStatus] = useState<'processing' | 'success' | 'error'>('processing')
const [message, setMessage] = useState('Processing GitHub authentication...')
const gqlClient = useGQLClient()
useEffect(() => {
const handleCallback = async () => {
try {
// Get parameters from URL
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
const errorDescription = searchParams.get('error_description')
// Check for OAuth errors
if (error) {
throw new Error(errorDescription || `GitHub OAuth error: ${error}`)
}
// Validate required parameters
if (!code) {
throw new Error('No authorization code received from GitHub')
}
// Verify state parameter for security
const storedState = sessionStorage.getItem('github_oauth_state')
if (state !== storedState) {
throw new Error('Invalid state parameter - possible CSRF attack')
}
// Clean up stored state
sessionStorage.removeItem('github_oauth_state')
setMessage('Connecting to backend...')
// Call backend's authenticateGitHub mutation
const result = await gqlClient.authenticateGitHub(code)
if (result.authenticateGitHub?.token) {
setStatus('success')
setMessage('GitHub authentication successful!')
// Notify parent window
if (window.opener) {
window.opener.postMessage({
type: 'GITHUB_BACKEND_AUTH_SUCCESS',
token: result.authenticateGitHub.token
}, window.location.origin)
}
// Close popup after a short delay
setTimeout(() => {
window.close()
}, 2000)
} else {
throw new Error('No token received from backend')
}
} catch (error) {
console.error('GitHub OAuth callback error:', error)
setStatus('error')
setMessage(error instanceof Error ? error.message : 'Unknown error occurred')
// Notify parent window of error
if (window.opener) {
window.opener.postMessage({
type: 'GITHUB_BACKEND_AUTH_ERROR',
message: error instanceof Error ? error.message : 'Unknown error'
}, window.location.origin)
}
// Close popup after delay even on error
setTimeout(() => {
window.close()
}, 5000)
}
}
// Only run if we have search params (meaning this is the callback)
if (searchParams.toString()) {
handleCallback()
}
}, [searchParams, gqlClient])
const getStatusIcon = () => {
switch (status) {
case 'processing':
return <Loader2 className="h-8 w-8 animate-spin text-blue-600" />
case 'success':
return <CheckCircle2 className="h-8 w-8 text-green-600" />
case 'error':
return <AlertCircle className="h-8 w-8 text-red-600" />
}
}
const getStatusColor = () => {
switch (status) {
case 'processing':
return 'text-blue-800'
case 'success':
return 'text-green-800'
case 'error':
return 'text-red-800'
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-4">
{getStatusIcon()}
</div>
<CardTitle>GitHub Authentication</CardTitle>
<CardDescription>
{status === 'processing' && 'Processing your GitHub authentication...'}
{status === 'success' && 'Authentication completed successfully'}
{status === 'error' && 'Authentication failed'}
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-center space-y-4">
<p className={`text-sm ${getStatusColor()}`}>
{message}
</p>
{status === 'success' && (
<div className="text-xs text-gray-500">
This window will close automatically...
</div>
)}
{status === 'error' && (
<div className="space-y-2">
<div className="text-xs text-gray-500">
This window will close automatically in a few seconds.
</div>
<button
onClick={() => window.close()}
className="text-sm text-blue-600 hover:text-blue-800 underline"
>
Close manually
</button>
</div>
)}
{status === 'processing' && (
<div className="text-xs text-gray-500">
Please wait while we complete the authentication process...
</div>
)}
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -4,6 +4,7 @@ import '@workspace/ui/globals.css'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { CheckBalanceWrapper } from '@/components/iframe/check-balance-iframe/CheckBalanceWrapper'
import { AutoSignInIFrameModal } from '@/components/iframe/auto-sign-in'
// Add root metadata with template pattern
export const metadata: Metadata = {
@ -29,6 +30,9 @@ export default function RootLayout({
<body className={`${inter.className} `} suppressHydrationWarning>
<main>
<Providers>{children}</Providers>
<div style={{ display: 'none' }}>
<AutoSignInIFrameModal />
</div>
<CheckBalanceWrapper />
</main>
</body>

View File

@ -0,0 +1,882 @@
// src/app/test-connection/page.tsx
'use client'
import { useState, useEffect } from 'react'
import { PageWrapper } from '@/components/foundation'
import { DirectKeyAuth } from '@/components/DirectKeyAuth'
import { GQLTest } from '@/components/GQLTest'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@workspace/ui/components/card'
import { useGQLClient } from '@/context'
import { Button } from '@workspace/ui/components/button'
import { Input } from '@workspace/ui/components/input'
import { Label } from '@workspace/ui/components/label'
import { Loader2, AlertTriangle, CheckCircle2, GitBranch } from 'lucide-react'
import { toast } from 'sonner'
import { useRepoData } from '@/hooks/useRepoData'
import { useAuth, useUser, SignIn } from "@clerk/nextjs"
import { GitHubBackendAuth } from '@/components/GitHubBackendAuth'
// Add this at the very top of your file
declare global {
interface Window {
Clerk?: {
session?: {
getToken: (options?: { template?: string }) => Promise<string | null>
}
}
}
}
export default function TestConnectionPage() {
// Get getToken from useAuth hook, not from user
const { isSignedIn, isLoaded: isClerkLoaded, getToken } = useAuth()
const { user, isLoaded: isUserLoaded } = useUser()
// Authentication states
const [isWalletConnected, setIsWalletConnected] = useState(false)
const [isBackendConnected, setIsBackendConnected] = useState(false)
const [isGithubAuthed, setIsGithubAuthed] = useState(false)
// Organization and deployment states
const [organizations, setOrganizations] = useState<any[]>([])
const [selectedOrg, setSelectedOrg] = useState<string>('')
const [isDeploying, setIsDeploying] = useState(false)
const [deploymentResult, setDeploymentResult] = useState<any>(null)
const [deploymentError, setDeploymentError] = useState<string | null>(null)
const [deployers, setDeployers] = useState<any[]>([])
const [selectedDeployer, setSelectedDeployer] = useState<string>('')
const [deployersLoading, setDeployersLoading] = useState(false)
// Form state
const [formData, setFormData] = useState({
name: 'test-deployment',
repository: '',
branch: 'main',
})
// Contexts and hooks
const gqlClient = useGQLClient()
// Use the useRepoData hook to get repositories (using Clerk's GitHub integration)
const { repoData: repositories } = useRepoData('')
// Check if both authentications are complete
const isFullyAuthenticated = isWalletConnected && isBackendConnected && isSignedIn && isGithubAuthed
// Add this near your other useState declarations at the top of the component
const [manualToken, setManualToken] = useState('')
// Update the function to use getToken from useAuth
const getClerkTokenForManualEntry = async () => {
if (!isSignedIn || !user) {
toast.error('Please sign in first')
return
}
try {
console.log('Attempting to get token from useAuth...')
// Method 1: Try getToken from useAuth hook
let token = null
try {
token = await getToken()
console.log('Method 1 (getToken from useAuth) worked:', token ? 'SUCCESS' : 'NO TOKEN')
} catch (error) {
console.log('Method 1 failed:', error)
}
// Method 2: Try with template parameter
if (!token) {
try {
token = await getToken({ template: 'github' })
console.log('Method 2 (getToken with github template) worked:', token ? 'SUCCESS' : 'NO TOKEN')
} catch (error) {
console.log('Method 2 failed:', error)
}
}
// Method 3: Try accessing window.Clerk (as mentioned in discussions)
if (!token && typeof window !== 'undefined' && window.Clerk) {
try {
token = await window.Clerk.session?.getToken()
console.log('Method 3 (window.Clerk.session.getToken) worked:', token ? 'SUCCESS' : 'NO TOKEN')
} catch (error) {
console.log('Method 3 failed:', error)
}
}
// Method 4: Try window.Clerk with template
if (!token && typeof window !== 'undefined' && window.Clerk) {
try {
token = await window.Clerk.session?.getToken({ template: 'github' })
console.log('Method 4 (window.Clerk with github template) worked:', token ? 'SUCCESS' : 'NO TOKEN')
} catch (error) {
console.log('Method 4 failed:', error)
}
}
if (token) {
setManualToken(token)
// Copy to clipboard automatically
navigator.clipboard.writeText(token)
toast.success('Token extracted and copied to clipboard')
console.log('GitHub token from Clerk:', token.substring(0, 20) + '...')
} else {
toast.error('Unable to extract GitHub token. Check console for details.')
console.log('GitHub account object:', user.externalAccounts?.find(account => account.provider === 'github'))
}
} catch (error) {
console.error('Error getting token from Clerk:', error)
toast.error(`Failed to get token: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}
// Check backend connection
const checkBackendConnection = async () => {
try {
// Test session
const response = await fetch('http://localhost:8000/auth/session', {
method: 'GET',
credentials: 'include',
})
if (response.ok) {
setIsBackendConnected(true)
console.log('Backend connected!')
// Check if user has GitHub token in backend
await checkBackendGithubAuth()
// Fetch organizations
try {
const orgsData = await gqlClient.getOrganizations()
console.log('Organizations:', orgsData)
setOrganizations(orgsData.organizations || [])
// Set default org if available
if (orgsData.organizations && orgsData.organizations.length > 0) {
setSelectedOrg(orgsData.organizations[0].slug)
}
} catch (error) {
console.error('Error fetching organizations:', error)
}
} else {
setIsBackendConnected(false)
console.log('Backend not connected')
}
} catch (error) {
console.error('Error checking backend connection:', error)
setIsBackendConnected(false)
}
}
// Check if user has GitHub token in the backend database
const checkBackendGithubAuth = async () => {
try {
// Try to get user data from backend
const userData = await gqlClient.getUser()
console.log('Backend user data:', userData)
// Check if user has GitHub token in backend
setIsGithubAuthed(!!userData.user.gitHubToken)
} catch (error) {
console.error('Error checking backend GitHub auth:', error)
setIsGithubAuthed(false)
}
}
// Sync GitHub token from Clerk to backend
// Check wallet connection status whenever the backend connection changes
useEffect(() => {
const checkWalletConnection = async () => {
if (isBackendConnected) {
try {
const response = await fetch('http://localhost:8000/auth/session', {
method: 'GET',
credentials: 'include',
})
setIsWalletConnected(response.ok)
} catch (error) {
console.error('Error checking wallet connection:', error)
setIsWalletConnected(false)
}
}
}
checkWalletConnection()
}, [isBackendConnected])
// Check backend connection on mount
useEffect(() => {
if (isClerkLoaded && isUserLoaded) {
checkBackendConnection()
}
}, [isClerkLoaded, isUserLoaded])
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
}
// Add this function to fetch deployers:
const fetchDeployers = async () => {
try {
setDeployersLoading(true)
const deployersData = await gqlClient.getDeployers()
console.log('Available deployers:', deployersData)
setDeployers(deployersData.deployers || [])
// Auto-select first deployer if available
if (deployersData.deployers && deployersData.deployers.length > 0) {
setSelectedDeployer(deployersData.deployers[0].deployerLrn)
}
} catch (error) {
console.error('Error fetching deployers:', error)
toast.error('Failed to fetch deployers')
} finally {
setDeployersLoading(false)
}
}
// Add this useEffect to fetch deployers when backend is connected:
useEffect(() => {
if (isBackendConnected && isFullyAuthenticated) {
fetchDeployers()
}
}, [isBackendConnected, isFullyAuthenticated])
// Updated handleDeploy function:
const handleDeploy = async () => {
if (!isFullyAuthenticated) {
setDeploymentError('Complete authentication required. Please authenticate with both wallet and GitHub.')
return
}
if (!selectedOrg) {
setDeploymentError('No organization selected')
return
}
if (!formData.repository) {
setDeploymentError('No repository selected')
return
}
if (!selectedDeployer) {
setDeploymentError('No deployer selected')
return
}
setIsDeploying(true)
setDeploymentError(null)
setDeploymentResult(null)
try {
console.log('🚀 Starting deployment with data:', {
...formData,
organizationSlug: selectedOrg,
deployerLrn: selectedDeployer
})
// Validate repository format
if (!formData.repository.includes('/')) {
throw new Error('Repository must be in format "owner/repo-name"')
}
const [owner, repo] = formData.repository.split('/')
if (!owner || !repo) {
throw new Error('Invalid repository format. Expected "owner/repo-name"')
}
console.log('📤 Calling backend addProject mutation...')
// Use the addProject mutation with deployer LRN
const result = await gqlClient.addProject(
selectedOrg,
{
name: formData.name,
repository: formData.repository,
prodBranch: formData.branch,
paymentAddress: "0x1ac42F4A25Ae0137d10a825a2e33e32de0F6B57E",
txHash: "0x0000000000000000000000000000000000000000000000000000000000000000"
},
selectedDeployer, // Pass the deployer LRN here
undefined, // auctionParams
[] // environmentVariables
)
console.log('Project creation result:', result)
if (result.addProject?.id) {
// Wait a moment to allow deployment to start
await new Promise(resolve => setTimeout(resolve, 2000))
// Get updated project data with deployments
const projectData = await gqlClient.getProject(result.addProject.id)
console.log('Project data with deployments:', projectData)
setDeploymentResult({
project: projectData.project,
message: 'Project created successfully!'
})
toast.success('Project deployed successfully!')
} else {
throw new Error('No project ID returned from creation')
}
} catch (error) {
console.error('Deployment failed:', error)
let errorMessage = 'Unknown error'
if (error instanceof Error) {
errorMessage = error.message
}
setDeploymentError(`Failed to deploy: ${errorMessage}`)
toast.error('Deployment failed')
} finally {
setIsDeploying(false)
}
}
return (
<PageWrapper
header={{
title: 'Connection & Deployment Test',
description: 'Test backend connection, authentication, and deployment functionality'
}}
>
<div className="grid grid-cols-1 gap-8 mt-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center">
Authentication Status
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="flex items-center">
<div className={`w-3 h-3 rounded-full mr-2 ${isWalletConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span>Wallet Connection: {isWalletConnected ? 'Connected' : 'Disconnected'}</span>
</div>
<div className="flex items-center">
<div className={`w-3 h-3 rounded-full mr-2 ${isBackendConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span>Backend Connection: {isBackendConnected ? 'Connected' : 'Disconnected'}</span>
</div>
<div className="flex items-center">
<div className={`w-3 h-3 rounded-full mr-2 ${isSignedIn ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span>Clerk Authentication: {isSignedIn ? 'Signed In' : 'Not Signed In'}</span>
</div>
<div className="flex items-center">
<div className={`w-3 h-3 rounded-full mr-2 ${isGithubAuthed ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span>GitHub (Backend): {isGithubAuthed ? 'Authenticated' : 'Not Authenticated'}</span>
</div>
</div>
<div className="mt-4">
<div className={`p-3 rounded-md ${isFullyAuthenticated ? 'bg-green-100 text-green-800' : 'bg-amber-100 text-amber-800'}`}>
<div className="flex items-center">
{isFullyAuthenticated ? (
<CheckCircle2 className="h-5 w-5 mr-2" />
) : (
<AlertTriangle className="h-5 w-5 mr-2" />
)}
<span className="font-medium">
{isFullyAuthenticated
? 'All authentication requirements met - Ready to deploy!'
: 'Complete all authentication steps to enable deployment'}
</span>
</div>
</div>
</div>
<Button
onClick={checkBackendConnection}
variant="outline"
size="sm"
className="mt-4"
>
Refresh Status
</Button>
</CardContent>
</Card>
<Tabs defaultValue="wallet">
<TabsList>
<TabsTrigger value="wallet">Wallet Auth</TabsTrigger>
<TabsTrigger value="clerk">Clerk Auth</TabsTrigger>
<TabsTrigger value="github">GitHub Sync</TabsTrigger>
<TabsTrigger value="gql">GraphQL</TabsTrigger>
<TabsTrigger value="deploy">Deployment</TabsTrigger>
</TabsList>
<TabsContent value="wallet">
<h2 className="text-xl font-semibold mb-4">Wallet Authentication</h2>
<p className="text-sm text-gray-600 mb-4">
This authenticates your wallet with the backend for payment processing and transaction signing.
</p>
<DirectKeyAuth />
</TabsContent>
<TabsContent value="clerk">
<h2 className="text-xl font-semibold mb-4">Clerk Authentication</h2>
<p className="text-sm text-gray-600 mb-4">
This provides GitHub authentication and user management through Clerk.
</p>
{!isSignedIn ? (
<Card>
<CardHeader>
<CardTitle>Sign In with Clerk</CardTitle>
<CardDescription>
Sign in to access GitHub repositories and user management features
</CardDescription>
</CardHeader>
<CardContent>
<SignIn />
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Clerk Authentication Status</CardTitle>
<CardDescription>
You are signed in with Clerk
</CardDescription>
</CardHeader>
<CardContent>
<div className="p-3 bg-green-100 text-green-800 rounded flex items-center mb-4">
<CheckCircle2 className="h-5 w-5 mr-2" />
<span>Successfully signed in with Clerk</span>
</div>
<div className="space-y-2">
<p><strong>User:</strong> {user?.emailAddresses[0]?.emailAddress}</p>
<p><strong>User ID:</strong> {user?.id}</p>
<p><strong>GitHub Connected:</strong> {
user?.externalAccounts.find(account => account.provider === 'github')
? 'Yes' : 'No'
}</p>
</div>
{!user?.externalAccounts.find(account => account.provider === 'github') && (
<div className="mt-4 p-3 bg-amber-100 text-amber-800 rounded">
<p className="text-sm">
You need to connect your GitHub account in Clerk to proceed.
</p>
<Button
onClick={() => window.open('https://accounts.clerk.dev/user', '_blank')}
className="mt-2"
size="sm"
>
Connect GitHub Account
</Button>
</div>
)}
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="github">
<h2 className="text-xl font-semibold mb-4">GitHub Authentication</h2>
<p className="text-sm text-gray-600 mb-4">
This page manages two separate GitHub connections for different purposes.
</p>
<div className="space-y-6">
{/* Clerk GitHub Integration */}
<Card>
<CardHeader>
<CardTitle>Clerk GitHub Integration</CardTitle>
<CardDescription>
Provides repository access and user management through Clerk
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center">
<div className={`w-3 h-3 rounded-full mr-2 ${isSignedIn ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span>Clerk Authentication: {isSignedIn ? 'Signed In' : 'Not Signed In'}</span>
</div>
<div className="flex items-center">
<div className={`w-3 h-3 rounded-full mr-2 ${
isSignedIn && user?.externalAccounts.find(account => account.provider === 'github')
? 'bg-green-500' : 'bg-red-500'
}`}></div>
<span>GitHub Connected to Clerk: {
isSignedIn && user?.externalAccounts.find(account => account.provider === 'github')
? 'Yes' : 'No'
}</span>
</div>
{repositories && repositories.length > 0 && (
<div>
<h3 className="text-md font-semibold mb-2">Available Repositories (via Clerk)</h3>
<div className="border rounded-md max-h-40 overflow-y-auto">
<ul className="divide-y">
{repositories.slice(0, 5).map((repo: any) => (
<li key={repo.id} className="p-2 text-sm">
<span className="font-medium">{repo.full_name}</span>
</li>
))}
{repositories.length > 5 && (
<li className="p-2 text-sm text-gray-500">
... and {repositories.length - 5} more repositories
</li>
)}
</ul>
</div>
</div>
)}
{/* Token extraction for debugging */}
<div className="border-t pt-4">
<h3 className="text-md font-semibold mb-2">Debug: Token Extraction</h3>
<Button
onClick={getClerkTokenForManualEntry}
disabled={!isSignedIn || !user?.externalAccounts.find(account => account.provider === 'github')}
variant="outline"
size="sm"
>
<GitBranch className="mr-2 h-4 w-4" />
Extract Clerk GitHub Token
</Button>
{manualToken && (
<div className="mt-3 space-y-2">
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded font-mono text-xs break-all">
{manualToken.substring(0, 40)}...
</div>
<div className="text-xs text-gray-600">
Token extracted successfully (showing first 40 characters)
</div>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Backend GitHub Authentication */}
<GitHubBackendAuth
onAuthStatusChange={(isAuth) => setIsGithubAuthed(isAuth)}
/>
{/* Status Summary */}
<Card>
<CardHeader>
<CardTitle>Authentication Summary</CardTitle>
<CardDescription>
Overview of all authentication systems
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-3">
<div className="flex items-center justify-between p-3 border rounded">
<span className="font-medium">Clerk GitHub (Repository Access)</span>
<div className="flex items-center">
<div className={`w-3 h-3 rounded-full mr-2 ${
isSignedIn && user?.externalAccounts.find(account => account.provider === 'github')
? 'bg-green-500' : 'bg-red-500'
}`}></div>
<span className="text-sm">
{isSignedIn && user?.externalAccounts.find(account => account.provider === 'github')
? 'Connected' : 'Not Connected'}
</span>
</div>
</div>
<div className="flex items-center justify-between p-3 border rounded">
<span className="font-medium">Backend GitHub (Deployments)</span>
<div className="flex items-center">
<div className={`w-3 h-3 rounded-full mr-2 ${isGithubAuthed ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span className="text-sm">{isGithubAuthed ? 'Connected' : 'Not Connected'}</span>
</div>
</div>
<div className="flex items-center justify-between p-3 border rounded">
<span className="font-medium">Wallet Authentication</span>
<div className="flex items-center">
<div className={`w-3 h-3 rounded-full mr-2 ${isWalletConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span className="text-sm">{isWalletConnected ? 'Connected' : 'Not Connected'}</span>
</div>
</div>
</div>
<div className={`mt-4 p-3 rounded-md ${isFullyAuthenticated ? 'bg-green-100 text-green-800' : 'bg-amber-100 text-amber-800'}`}>
<div className="flex items-center">
{isFullyAuthenticated ? (
<CheckCircle2 className="h-5 w-5 mr-2" />
) : (
<AlertTriangle className="h-5 w-5 mr-2" />
)}
<span className="font-medium">
{isFullyAuthenticated
? 'All systems connected - Ready for deployment!'
: 'Complete all authentication steps to enable deployment'}
</span>
</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="gql">
<h2 className="text-xl font-semibold mb-4">GraphQL Testing</h2>
<GQLTest />
</TabsContent>
<TabsContent value="deploy">
<h2 className="text-xl font-semibold mb-4">Deployment Testing</h2>
{!isFullyAuthenticated ? (
<Card>
<CardHeader>
<CardTitle>Complete Authentication Required</CardTitle>
<CardDescription>
You need to complete all authentication steps before deploying
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-2">
<div className="p-3 border rounded flex items-center">
<div className={`w-3 h-3 rounded-full mr-2 ${isWalletConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span>Wallet Authentication: {isWalletConnected ? 'Complete' : 'Required'}</span>
</div>
<div className="p-3 border rounded flex items-center">
<div className={`w-3 h-3 rounded-full mr-2 ${isSignedIn ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span>Clerk Authentication: {isSignedIn ? 'Complete' : 'Required'}</span>
</div>
<div className="p-3 border rounded flex items-center">
<div className={`w-3 h-3 rounded-full mr-2 ${isGithubAuthed ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span>GitHub Backend Sync: {isGithubAuthed ? 'Complete' : 'Required'}</span>
</div>
</div>
<div className="p-4 bg-blue-50 rounded-md">
<h3 className="text-sm font-medium text-blue-800 mb-2">Next Steps:</h3>
<ol className="list-decimal pl-4 text-sm text-blue-700 space-y-1">
{!isWalletConnected && <li>Complete wallet authentication in the Wallet Auth tab</li>}
{!isSignedIn && <li>Sign in with Clerk in the Clerk Auth tab</li>}
{!isGithubAuthed && <li>Sync GitHub token in the GitHub Sync tab</li>}
</ol>
</div>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>Test Deployment</CardTitle>
<CardDescription>
Deploy a test project to verify deployment functionality
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{organizations.length > 0 ? (
<div className="space-y-2">
<Label htmlFor="organization">Organization</Label>
<select
id="organization"
className="w-full p-2 border rounded"
value={selectedOrg}
onChange={(e) => setSelectedOrg(e.target.value)}
>
{organizations.map(org => (
<option key={org.id} value={org.slug}>
{org.name} ({org.slug})
</option>
))}
</select>
</div>
) : (
<div className="p-3 bg-amber-100 text-amber-800 rounded">
No organizations found. You need to be part of at least one organization.
</div>
)}
{/* Deployer Selection */}
<div className="space-y-2">
<Label htmlFor="deployer">Deployer</Label>
{deployersLoading ? (
<div className="p-2 border rounded bg-gray-50">
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-gray-900 mr-2"></div>
Loading deployers...
</div>
</div>
) : deployers.length > 0 ? (
<select
id="deployer"
className="w-full p-2 border rounded"
value={selectedDeployer}
onChange={(e) => setSelectedDeployer(e.target.value)}
>
<option value="">Select a deployer</option>
{deployers.map((deployer) => (
<option key={deployer.deployerLrn} value={deployer.deployerLrn}>
{deployer.deployerLrn}
{deployer.minimumPayment && ` (Min: ${deployer.minimumPayment})`}
</option>
))}
</select>
) : (
<div className="p-3 bg-amber-100 text-amber-800 rounded">
<p className="text-sm">
No deployers available. The backend needs to have deployers configured.
</p>
<Button
onClick={fetchDeployers}
variant="outline"
size="sm"
className="mt-2"
>
Refresh Deployers
</Button>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="name">Project Name</Label>
<Input
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="test-deployment"
/>
</div>
<div className="space-y-2">
<Label htmlFor="repository">Repository</Label>
{repositories && repositories.length > 0 ? (
<select
id="repository"
name="repository"
className="w-full p-2 border rounded"
value={formData.repository}
onChange={handleChange}
>
<option value="">Select a repository</option>
{repositories.map((repo: any) => (
<option key={repo.id} value={repo.full_name}>
{repo.full_name}
</option>
))}
</select>
) : (
<div>
<p className="mb-2 text-sm text-amber-800">
Enter the repository manually (format: owner/repo-name)
</p>
<Input
id="repository"
name="repository"
value={formData.repository}
onChange={handleChange}
placeholder="username/repo-name"
/>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="branch">Branch</Label>
<Input
id="branch"
name="branch"
value={formData.branch}
onChange={handleChange}
placeholder="main or master"
/>
</div>
<Button
onClick={handleDeploy}
disabled={isDeploying || !selectedOrg || !formData.repository || !selectedDeployer}
className="w-full"
>
{isDeploying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deploying...
</>
) : 'Deploy Test Project'}
</Button>
{deploymentError && (
<div className="p-3 bg-red-100 text-red-800 rounded">
{deploymentError}
</div>
)}
{deploymentResult && (
<div className="p-3 bg-green-100 text-green-800 rounded">
<div className="flex items-start">
<CheckCircle2 className="h-5 w-5 mr-2 mt-0.5" />
<div>
<h3 className="font-medium">{deploymentResult.message}</h3>
<p className="text-sm mt-1">Project ID: {deploymentResult.project?.id}</p>
<p className="text-sm">Name: {deploymentResult.project?.name}</p>
<p className="text-sm">Repository: {deploymentResult.project?.repository}</p>
</div>
</div>
<details className="mt-2">
<summary className="cursor-pointer text-sm font-medium">
Show full project details
</summary>
<pre className="bg-white p-2 rounded mt-1 overflow-auto max-h-64 text-xs">
{JSON.stringify(deploymentResult.project, null, 2)}
</pre>
</details>
</div>
)}
</div>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
<div className="mt-8 p-4 bg-gray-100 dark:bg-gray-800 rounded-md">
<h2 className="text-lg font-semibold mb-2">Hybrid Authentication Flow</h2>
<p className="mb-2 text-sm">
This deployment system requires both wallet and GitHub authentication:
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<h3 className="text-md font-semibold mb-2">Wallet Authentication (DirectKeyAuth)</h3>
<ul className="list-disc pl-5 space-y-1 text-sm">
<li>Provides Ethereum wallet connection</li>
<li>Enables transaction signing for payments</li>
<li>Required for deployment costs and blockchain operations</li>
</ul>
</div>
<div>
<h3 className="text-md font-semibold mb-2">GitHub Authentication (Clerk)</h3>
<ul className="list-disc pl-5 space-y-1 text-sm">
<li>Provides access to GitHub repositories</li>
<li>Enables repository cloning and deployment</li>
<li>Required for backend deployment operations</li>
</ul>
</div>
</div>
</div>
</PageWrapper>
)
}

View File

@ -0,0 +1,411 @@
// src/components/SIWEAuth.tsx with raw signature approach
'use client'
import { useState, useEffect } from 'react'
import { useWallet } from '@/context/WalletContext'
import { Button } from '@workspace/ui/components/button'
import { CheckBalanceWrapper } from './iframe/check-balance-iframe/CheckBalanceWrapper'
import { CopyIcon } from 'lucide-react'
// Generate a random nonce
function generateNonce() {
return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
}
export function SIWEAuth() {
const { wallet, isConnected, connect, disconnect } = useWallet()
const [sessionStatus, setSessionStatus] = useState<'checking' | 'authenticated' | 'unauthenticated'>('checking')
const [sessionData, setSessionData] = useState<any>(null)
const [isAuthenticating, setIsAuthenticating] = useState(false)
const [authError, setAuthError] = useState<string | null>(null)
const [signedMessage, setSignedMessage] = useState<string | null>(null)
const [messageToSign, setMessageToSign] = useState<string | null>(null)
const [debugInfo, setDebugInfo] = useState<string>('')
// Check if we already have a session
const checkSession = async () => {
try {
setSessionStatus('checking')
const response = await fetch('http://localhost:8000/auth/session', {
method: 'GET',
credentials: 'include',
})
if (response.ok) {
const data = await response.json()
setSessionStatus('authenticated')
setSessionData(data)
console.log('Session check successful:', data)
} else {
setSessionStatus('unauthenticated')
setSessionData(null)
console.log('Session check failed:', await response.text())
}
} catch (error) {
console.error('Error checking session:', error)
setSessionStatus('unauthenticated')
setSessionData(null)
}
}
// Check session on component mount
useEffect(() => {
checkSession()
}, [])
// Copy text to clipboard
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
.then(() => {
console.log('Text copied to clipboard')
})
.catch(err => {
console.error('Could not copy text: ', err)
})
}
// Create a SIWE message with the correct Ethereum address
const createSiweMessage = () => {
// We want to try both our displayed address and the expected one from errors
// We'll use the displayed address by default
const ethAddress = '0x1ac42F4A25Ae0137d10a825a2e33e32de0F6B57E';
const domain = window.location.host
const origin = window.location.origin
const chainId = 1 // Ethereum mainnet
const statement = 'Sign in With Ethereum.'
const nonce = generateNonce()
const issuedAt = new Date().toISOString()
// IMPORTANT: This format must exactly match what the SiweMessage constructor expects
return `${domain} wants you to sign in with your Ethereum account:
${ethAddress}
${statement}
URI: ${origin}
Version: 1
Chain ID: ${chainId}
Nonce: ${nonce}
Issued At: ${issuedAt}`
}
// Generate the message for signing
const generateMessageToSign = async () => {
if (!wallet?.address) {
setAuthError('Wallet not connected')
return
}
try {
setIsAuthenticating(true)
setAuthError(null)
// Create a SIWE message with the Ethereum address
const message = createSiweMessage()
console.log('SIWE Message with Ethereum address:', message)
setDebugInfo(`Generated message with Ethereum address. IMPORTANT: Make sure "Ethereum" is selected in the wallet dropdown when signing.`)
// Set the message to sign
setMessageToSign(message)
} catch (error) {
console.error('Error generating message:', error)
setAuthError(`Error: ${error instanceof Error ? error.message : String(error)}`)
} finally {
setIsAuthenticating(false)
}
}
// Check auth without sending signature
const checkAuthWithoutSignature = async () => {
try {
setIsAuthenticating(true)
setAuthError(null)
setDebugInfo('Trying auth without signature...')
// Create API route to handle this
const response = await fetch('/api/dev-auth', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
address: '0x1ac42F4A25Ae0137d10a825a2e33e32de0F6B57E'
})
})
const data = await response.json()
console.log('Dev auth response:', data)
setDebugInfo(prev => `${prev}\nDev auth response: ${JSON.stringify(data)}`)
if (response.ok && data.success) {
console.log('Dev auth successful!')
setDebugInfo(prev => `${prev}\nDev auth successful!`)
await checkSession()
} else {
throw new Error(`Dev auth failed: ${JSON.stringify(data)}`)
}
} catch (error) {
console.error('Dev auth error:', error)
setAuthError(`Error: ${error instanceof Error ? error.message : String(error)}`)
} finally {
setIsAuthenticating(false)
}
}
// Submit signature to validate
const submitSignature = async () => {
if (!messageToSign || !signedMessage) {
setAuthError('Missing message or signature')
return
}
try {
setIsAuthenticating(true)
setAuthError(null)
setDebugInfo(prev => `${prev}\nSubmitting raw signature...`)
// Log the original signature
console.log('Raw signature:', signedMessage)
setDebugInfo(prev => `${prev}\nRaw signature: ${signedMessage}`)
// Try using the raw signature directly
const response = await fetch('http://localhost:8000/auth/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
message: messageToSign,
signature: signedMessage
})
})
let responseData = {}
try {
responseData = await response.json()
} catch (e) {
console.log('Error parsing response:', e)
}
console.log('Validation response:', responseData)
setDebugInfo(prev => `${prev}\nValidation response: ${JSON.stringify(responseData)}`)
// If successful, we're done
if (response.ok && responseData.success) {
console.log('Authentication successful!')
setDebugInfo(prev => `${prev}\nAuthentication successful!`)
// Clear message and signature
setMessageToSign(null)
setSignedMessage(null)
// Check if we now have a session
await checkSession()
return
}
// If we get here, it failed
throw new Error(`Validation failed: ${JSON.stringify(responseData)}`)
} catch (error) {
console.error('Authentication error:', error)
setAuthError(`Error: ${error instanceof Error ? error.message : String(error)}`)
setSessionStatus('unauthenticated')
} finally {
setIsAuthenticating(false)
}
}
return (
<div className="p-4 border rounded-md">
{/* Hidden iframe for wallet connection */}
<CheckBalanceWrapper />
<h2 className="text-lg font-bold mb-4">Sign-In With Ethereum</h2>
<div className="mb-4">
<h3 className="text-md font-semibold mb-2">Wallet Status</h3>
<p className="mb-2">
Status: <span className={isConnected ? "text-green-500" : "text-red-500"}>
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</p>
{wallet && wallet.address && (
<div className="p-2 bg-gray-800 text-white rounded mb-2">
<p className="font-mono text-sm break-all">Laconic Address: {wallet.address}</p>
<p className="font-mono text-sm break-all mt-1">Ethereum Address: 0x1ac42F4A25Ae0137d10a825a2e33e32de0F6B57E</p>
</div>
)}
<div className="mt-2">
{!isConnected ? (
<Button onClick={connect}>Connect Wallet</Button>
) : (
<Button variant="outline" onClick={disconnect}>Disconnect</Button>
)}
</div>
</div>
{isConnected && sessionStatus !== 'authenticated' && (
<div className="mb-4">
<h3 className="text-md font-semibold mb-2">Authentication</h3>
<div className="p-3 bg-amber-100 text-amber-800 rounded mb-4">
<p className="font-semibold text-sm">IMPORTANT:</p>
<p className="text-sm">When signing the message, make sure "Ethereum" is selected in the wallet's network dropdown.</p>
</div>
{!messageToSign ? (
<div className="space-y-4">
<Button
onClick={generateMessageToSign}
disabled={isAuthenticating}
className="w-full"
>
Generate SIWE Message
</Button>
<div className="border-t pt-4">
<p className="text-sm mb-2 text-amber-700 font-semibold">
Alternative Authentication Methods
</p>
<Button
onClick={checkAuthWithoutSignature}
disabled={isAuthenticating}
variant="outline"
className="w-full"
>
Try Development Authentication
</Button>
<p className="text-xs text-gray-500 mt-1">
This will try to create a session using a development-only endpoint.
</p>
</div>
</div>
) : (
<div className="mb-4">
<div className="p-3 bg-gray-800 text-white rounded mb-3">
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-semibold">Message to Sign:</h4>
<Button
size="sm"
variant="ghost"
onClick={() => copyToClipboard(messageToSign)}
className="h-8 px-2"
>
<CopyIcon className="h-4 w-4 mr-1" /> Copy
</Button>
</div>
<pre className="font-mono text-xs whitespace-pre-wrap break-all">{messageToSign}</pre>
</div>
<div className="mb-3">
<p className="text-sm mb-2">
1. Copy the message above
</p>
<p className="text-sm mb-2">
2. Go to your wallet's "Sign Message" page
(<a href="http://localhost:4000/SignMessage" target="_blank" className="text-blue-500 underline">
Open Wallet Sign Page
</a>)
</p>
<p className="text-sm mb-2 font-medium text-amber-700">
3. Make sure "Ethereum" is selected in the network dropdown
</p>
<p className="text-sm mb-2">
4. Paste the message and sign it
</p>
<p className="text-sm mb-2">
5. Copy the ENTIRE signature and paste it below
</p>
</div>
<div className="mb-3">
<h4 className="text-sm font-semibold mb-2">Paste Signature:</h4>
<textarea
className="w-full p-2 border rounded dark:bg-gray-800 dark:text-white"
rows={3}
value={signedMessage || ''}
onChange={(e) => setSignedMessage(e.target.value)}
placeholder="Paste signature here (including 'Signature' prefix)"
/>
</div>
<Button
onClick={submitSignature}
disabled={isAuthenticating || !signedMessage}
className="mb-2"
>
{isAuthenticating ? 'Validating...' : 'Validate Signature'}
</Button>
<Button
variant="outline"
onClick={() => {
setMessageToSign(null);
setSignedMessage(null);
setDebugInfo('');
}}
className="ml-2 mb-2"
>
Cancel
</Button>
</div>
)}
{debugInfo && (
<div className="mt-4 p-2 bg-gray-800 text-white rounded">
<h4 className="text-sm font-semibold mb-2">Debug Information:</h4>
<pre className="font-mono text-xs whitespace-pre-wrap">{debugInfo}</pre>
</div>
)}
{authError && (
<div className="mt-2 p-2 bg-red-100 border border-red-300 text-red-800 rounded whitespace-pre-line">
{authError}
</div>
)}
</div>
)}
<div className="mb-4">
<h3 className="text-md font-semibold mb-2">Backend Session</h3>
<p className="mb-2">
Status:
<span className={
sessionStatus === 'authenticated' ? "text-green-500" :
sessionStatus === 'unauthenticated' ? "text-red-500" :
"text-yellow-500"
}>
{' '}{sessionStatus}
</span>
</p>
{sessionData && (
<div className="p-2 bg-gray-800 text-white rounded mb-2">
<pre className="font-mono text-sm overflow-auto max-h-32">{JSON.stringify(sessionData, null, 2)}</pre>
</div>
)}
<div className="mt-2">
<Button variant="outline" onClick={checkSession}>Check Session</Button>
</div>
</div>
<div className="mt-4 p-3 bg-blue-50 text-blue-800 rounded text-sm">
<p className="font-semibold mb-1">About Laconic Wallet Authentication:</p>
<p className="mt-2 text-xs">
The Laconic wallet supports multiple networks including Ethereum. For SIWE authentication, you must:
</p>
<ol className="list-decimal text-xs mt-1 pl-4">
<li>Use your Ethereum address in the sign-in message</li>
<li>Make sure "Ethereum" is selected in the network dropdown when signing</li>
<li>The signature will then be created with your Ethereum private key</li>
</ol>
</div>
</div>
)
}

View File

@ -0,0 +1,171 @@
'use client'
import { useState } from 'react'
import { Button } from '@workspace/ui/components/button'
import { Input } from '@workspace/ui/components/input'
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@workspace/ui/components/card'
import { Label } from '@workspace/ui/components/label'
import { useDeployment, type DeploymentConfig } from '@/hooks/useDeployment'
import { Loader2 } from 'lucide-react'
interface DeploymentFormProps {
organizationSlug: string
}
export function DeploymentForm({ organizationSlug }: DeploymentFormProps) {
const { deployRepository, isDeploying, deploymentResult } = useDeployment()
const [formData, setFormData] = useState<Omit<DeploymentConfig, 'organizationSlug'>>({
projectId: '',
repository: '',
branch: 'main',
name: '',
environmentVariables: []
})
const [envVars, setEnvVars] = useState<Array<{ key: string; value: string }>>([])
const [currentEnvVar, setCurrentEnvVar] = useState({ key: '', value: '' })
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
}
const handleAddEnvVar = () => {
if (currentEnvVar.key && currentEnvVar.value) {
setEnvVars(prev => [...prev, { ...currentEnvVar }])
setCurrentEnvVar({ key: '', value: '' })
}
}
const handleDeploy = async () => {
try {
// Convert the env vars to the format expected by the API
const environmentVariables = envVars.map(ev => ({
key: ev.key,
value: ev.value,
environments: ['Production', 'Preview'] // Default to both environments
}))
await deployRepository({
...formData,
organizationSlug,
environmentVariables
})
} catch (error) {
console.error('Deployment failed:', error)
}
}
return (
<Card className="w-full">
<CardHeader>
<CardTitle>Deploy Repository</CardTitle>
<CardDescription>
Enter the details for deploying a GitHub repository
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Project Name</Label>
<Input
id="name"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="my-awesome-project"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="repository">Repository URL</Label>
<Input
id="repository"
name="repository"
value={formData.repository}
onChange={handleChange}
placeholder="https://github.com/username/repo"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="branch">Branch</Label>
<Input
id="branch"
name="branch"
value={formData.branch}
onChange={handleChange}
placeholder="main"
/>
</div>
<div className="space-y-2">
<Label>Environment Variables</Label>
<div className="grid grid-cols-2 gap-2">
<Input
placeholder="KEY"
value={currentEnvVar.key}
onChange={(e) => setCurrentEnvVar(prev => ({ ...prev, key: e.target.value }))}
/>
<Input
placeholder="value"
value={currentEnvVar.value}
onChange={(e) => setCurrentEnvVar(prev => ({ ...prev, value: e.target.value }))}
/>
</div>
<Button
variant="outline"
type="button"
onClick={handleAddEnvVar}
disabled={!currentEnvVar.key || !currentEnvVar.value}
className="mt-2"
>
Add Environment Variable
</Button>
</div>
{envVars.length > 0 && (
<div className="border rounded p-2">
<h4 className="font-medium mb-2">Environment Variables:</h4>
<ul className="space-y-1">
{envVars.map((ev, index) => (
<li key={index} className="flex justify-between">
<span className="font-mono">{ev.key}</span>
<span className="font-mono text-gray-500">{ev.value}</span>
</li>
))}
</ul>
</div>
)}
</div>
</CardContent>
<CardFooter>
<Button
onClick={handleDeploy}
disabled={isDeploying || !formData.name || !formData.repository}
className="w-full"
>
{isDeploying ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deploying...
</>
) : 'Deploy Repository'}
</Button>
</CardFooter>
{deploymentResult && (
<div className="mt-4 p-4 border-t">
<h3 className="font-medium mb-2">Deployment Result:</h3>
<p>Status: <span className="font-medium">{deploymentResult.status}</span></p>
{deploymentResult.url && (
<p className="mt-2">
URL: <a href={deploymentResult.url} target="_blank" rel="noopener noreferrer" className="text-blue-500 underline">{deploymentResult.url}</a>
</p>
)}
</div>
)}
</Card>
)
}

View File

@ -0,0 +1,160 @@
// src/components/DirectKeyAuth.tsx
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@workspace/ui/components/button'
import { Wallet } from 'ethers' // Add this to your package.json if not already there
export function DirectKeyAuth() {
const [sessionStatus, setSessionStatus] = useState<'checking' | 'authenticated' | 'unauthenticated'>('checking')
const [sessionData, setSessionData] = useState<any>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// Check if we already have a session
const checkSession = async () => {
try {
setSessionStatus('checking')
const response = await fetch('http://localhost:8000/auth/session', {
method: 'GET',
credentials: 'include',
})
if (response.ok) {
const data = await response.json()
setSessionStatus('authenticated')
setSessionData(data)
console.log('Session check successful:', data)
} else {
setSessionStatus('unauthenticated')
setSessionData(null)
console.log('Session check failed:', await response.text())
}
} catch (error) {
console.error('Error checking session:', error)
setSessionStatus('unauthenticated')
setSessionData(null)
}
}
// Check session on component mount
useEffect(() => {
checkSession()
}, [])
// Sign in with private key
const signInWithKey = async () => {
try {
setIsLoading(true)
setError(null)
// Create wallet from private key
const privateKey = '0x23ad64eabeba406086636c621893370c32d8678b5c879195ed4616e842b7aa42';
const wallet = new Wallet(privateKey);
// Get the address
const address = wallet.address;
console.log('Derived address:', address);
// Create SIWE message
const domain = window.location.host;
const origin = window.location.origin;
const nonce = Math.random().toString(36).slice(2);
const issuedAt = new Date().toISOString();
const message = `${domain} wants you to sign in with your Ethereum account:
${address}
Sign in With Ethereum.
URI: ${origin}
Version: 1
Chain ID: 1
Nonce: ${nonce}
Issued At: ${issuedAt}`;
console.log('Message to sign:', message);
// Sign the message
const signature = await wallet.signMessage(message);
console.log('Generated signature:', signature);
// Send to backend
const response = await fetch('http://localhost:8000/auth/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
message,
signature
})
});
const responseData = await response.text();
console.log('Response data:', responseData);
if (response.ok) {
console.log('Authentication successful!');
await checkSession();
} else {
setError(`Authentication failed: ${responseData}`);
}
} catch (error) {
console.error('Error signing in with key:', error);
setError(`Error: ${error instanceof Error ? error.message : String(error)}`);
} finally {
setIsLoading(false);
}
};
return (
<div className="p-4 border rounded-md">
<h2 className="text-lg font-bold mb-4">Direct Key Authentication</h2>
<div className="mb-4">
<p className="text-amber-600 text-sm mb-2">
This component uses a local private key to sign messages directly, bypassing the wallet UI.
</p>
<Button
onClick={signInWithKey}
disabled={isLoading}
className="mb-2"
>
{isLoading ? 'Authenticating...' : 'Sign In With Private Key'}
</Button>
</div>
<div className="mb-4">
<h3 className="text-md font-semibold mb-2">Backend Session</h3>
<p className="mb-2">
Status:
<span className={
sessionStatus === 'authenticated' ? "text-green-500" :
sessionStatus === 'unauthenticated' ? "text-red-500" :
"text-yellow-500"
}>
{' '}{sessionStatus}
</span>
</p>
{sessionData && (
<div className="p-2 bg-gray-800 text-white rounded mb-2">
<pre className="font-mono text-sm overflow-auto max-h-32">{JSON.stringify(sessionData, null, 2)}</pre>
</div>
)}
<div className="mt-2">
<Button variant="outline" onClick={checkSession}>Check Session</Button>
</div>
</div>
{error && (
<div className="mt-2 p-2 bg-red-100 border border-red-300 text-red-800 rounded">
{error}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,93 @@
'use client'
import { useEffect, useState } from 'react'
import { useGQLClient } from '@/context'
export function GQLTest() {
const [testResponse, setTestResponse] = useState<string>('Testing connection...')
const [error, setError] = useState<string | null>(null)
const gqlClient = useGQLClient()
useEffect(() => {
async function testGQLConnection() {
try {
// Try a direct GraphQL query using fetch
const response = await fetch('http://localhost:8000/graphql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // Important for sending cookies
body: JSON.stringify({
query: `
{
__schema {
queryType {
name
}
}
}
`
})
})
const data = await response.json()
setTestResponse(JSON.stringify(data, null, 2))
// Check server logs to see if our request arrived
console.log('GraphQL test response:', data)
} catch (err) {
console.error('Error testing GraphQL connection:', err)
setError(err instanceof Error ? err.message : String(err))
}
}
testGQLConnection()
}, [gqlClient])
// Function to test direct connection
const testDirectConnection = async () => {
try {
setTestResponse('Testing direct connection...')
setError(null)
const response = await fetch('http://localhost:8000/auth/session', {
method: 'GET',
credentials: 'include',
})
const data = await response.json()
setTestResponse(JSON.stringify(data, null, 2))
} catch (err) {
console.error('Error testing direct connection:', err)
setError(err instanceof Error ? err.message : String(err))
}
}
return (
<div className="p-4 border rounded-md shadow-sm">
<h2 className="text-lg font-bold mb-2">GraphQL Connection Test</h2>
<div className="mb-4">
<button
onClick={testDirectConnection}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
Test Direct Connection
</button>
</div>
{error ? (
<div className="text-red-500">
<p>Error connecting to GraphQL server:</p>
<pre className="bg-gray-900 p-2 rounded overflow-auto max-h-48">{error}</pre>
<p className="mt-2">
Authentication error is expected without a valid session. The GQL server requires authentication.
</p>
<p>Check the server logs to see if the request was received.</p>
</div>
) : (
<pre className="bg-gray-900 p-2 rounded overflow-auto max-h-48">{testResponse}</pre>
)}
</div>
)
}

View File

@ -0,0 +1,241 @@
// src/components/GitHubBackendAuth.tsx
'use client'
import { useState, useEffect } from 'react'
import { Button } from '@workspace/ui/components/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@workspace/ui/components/card'
import { CheckCircle2, GitBranch, ExternalLink, AlertCircle } from 'lucide-react'
import { toast } from 'sonner'
import { useGQLClient } from '@/context'
interface GitHubBackendAuthProps {
onAuthStatusChange?: (isAuthenticated: boolean) => void
}
export function GitHubBackendAuth({ onAuthStatusChange }: GitHubBackendAuthProps) {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [isChecking, setIsChecking] = useState(true)
const gqlClient = useGQLClient()
// GitHub OAuth configuration - replace with your backend OAuth app credentials
const GITHUB_CLIENT_ID = process.env.NEXT_PUBLIC_GITHUB_BACKEND_CLIENT_ID || 'your_backend_client_id_here'
const REDIRECT_URI = `${window.location.origin}/auth/github/backend-callback`
// Check current authentication status
const checkAuthStatus = async () => {
try {
setIsChecking(true)
const userData = await gqlClient.getUser()
const hasGitHubToken = !!userData.user.gitHubToken
setIsAuthenticated(hasGitHubToken)
onAuthStatusChange?.(hasGitHubToken)
} catch (error) {
console.error('Error checking GitHub auth status:', error)
setIsAuthenticated(false)
onAuthStatusChange?.(false)
} finally {
setIsChecking(false)
}
}
// Check auth status on mount
useEffect(() => {
checkAuthStatus()
}, [])
// Listen for OAuth callback completion
useEffect(() => {
const handleAuthComplete = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return
if (event.data.type === 'GITHUB_BACKEND_AUTH_SUCCESS') {
toast.success('GitHub backend authentication successful!')
checkAuthStatus()
} else if (event.data.type === 'GITHUB_BACKEND_AUTH_ERROR') {
toast.error(`GitHub authentication failed: ${event.data.message}`)
setIsLoading(false)
}
}
window.addEventListener('message', handleAuthComplete)
return () => window.removeEventListener('message', handleAuthComplete)
}, [])
const startGitHubAuth = () => {
setIsLoading(true)
// Generate state parameter for security
const state = Math.random().toString(36).substring(2, 15)
sessionStorage.setItem('github_oauth_state', state)
// Build GitHub OAuth URL
const params = new URLSearchParams({
client_id: GITHUB_CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: 'repo public_repo user:email',
state: state,
response_type: 'code'
})
const authUrl = `https://github.com/login/oauth/authorize?${params.toString()}`
// Open OAuth in popup window
const popup = window.open(
authUrl,
'github-oauth',
'width=600,height=700,scrollbars=yes,resizable=yes'
)
// Monitor popup closure
const checkClosed = setInterval(() => {
if (popup?.closed) {
clearInterval(checkClosed)
setIsLoading(false)
}
}, 1000)
}
const disconnectGitHub = async () => {
try {
setIsLoading(true)
await gqlClient.unauthenticateGithub()
setIsAuthenticated(false)
onAuthStatusChange?.(false)
toast.success('GitHub disconnected successfully')
} catch (error) {
console.error('Error disconnecting GitHub:', error)
toast.error('Failed to disconnect GitHub')
} finally {
setIsLoading(false)
}
}
if (isChecking) {
return (
<Card>
<CardHeader>
<CardTitle>GitHub Backend Authentication</CardTitle>
<CardDescription>Checking authentication status...</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center p-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-900"></div>
</div>
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<GitBranch className="mr-2 h-5 w-5" />
GitHub Backend Authentication
</CardTitle>
<CardDescription>
Connect your GitHub account to the backend for deployment operations
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center">
<div className={`w-3 h-3 rounded-full mr-2 ${
isAuthenticated ? 'bg-green-500' : 'bg-red-500'
}`}></div>
<span>
Backend GitHub Token: {isAuthenticated ? 'Connected' : 'Not Connected'}
</span>
</div>
{!isAuthenticated ? (
<div className="space-y-3">
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-md">
<div className="flex items-start">
<AlertCircle className="h-5 w-5 text-blue-600 mr-2 mt-0.5" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">Backend GitHub Authentication Required</p>
<p>
This connects your GitHub account directly to the backend for deployment operations.
This is separate from your Clerk GitHub integration.
</p>
</div>
</div>
</div>
<Button
onClick={startGitHubAuth}
disabled={isLoading}
className="w-full"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Connecting...
</>
) : (
<>
<ExternalLink className="mr-2 h-4 w-4" />
Connect GitHub to Backend
</>
)}
</Button>
<div className="text-xs text-gray-500 text-center">
This will open GitHub in a popup window for authentication
</div>
</div>
) : (
<div className="space-y-3">
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-md">
<div className="flex items-center">
<CheckCircle2 className="h-5 w-5 text-green-600 mr-2" />
<div className="text-sm text-green-800 dark:text-green-200">
<p className="font-medium">GitHub Backend Connected Successfully</p>
<p>Your backend can now access GitHub for deployments</p>
</div>
</div>
</div>
<div className="flex space-x-2">
<Button
onClick={checkAuthStatus}
variant="outline"
size="sm"
className="flex-1"
>
Refresh Status
</Button>
<Button
onClick={disconnectGitHub}
variant="outline"
size="sm"
className="flex-1"
disabled={isLoading}
>
{isLoading ? 'Disconnecting...' : 'Disconnect'}
</Button>
</div>
</div>
)}
{!GITHUB_CLIENT_ID.startsWith('your_') ? null : (
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md">
<div className="flex items-start">
<AlertCircle className="h-5 w-5 text-amber-600 mr-2 mt-0.5" />
<div className="text-sm text-amber-800 dark:text-amber-200">
<p className="font-medium mb-1">Configuration Required</p>
<p>
Please set <code>NEXT_PUBLIC_GITHUB_BACKEND_CLIENT_ID</code> in your environment variables
with your backend GitHub OAuth app client ID.
</p>
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
)
}

View File

@ -10,7 +10,7 @@ import {
SheetTitle,
SheetTrigger
} from '@workspace/ui/components/sheet'
import { CreditCard, Menu, Shapes, WalletIcon } from 'lucide-react'
import { CreditCard, Menu, Shapes } from 'lucide-react'
import Link from 'next/link'
import type React from 'react'
import { DarkModeToggle } from '../dark-mode-toggle'
@ -202,10 +202,9 @@ export default function TopNavigation({
config = {
leftItems: [
{ icon: Shapes, label: 'Projects', href: '/projects' },
{ icon: WalletIcon, label: 'Wallet', href: '/wallet' },
{ icon: CreditCard, label: 'Purchase', href: '/purchase' }
],
rightItems: [
{ icon: CreditCard, label: 'Purchase', href: '/purchase' },
{ label: 'Support', href: '/support' },
{ label: 'Documentation', href: '/documentation' }
]

View File

@ -67,7 +67,6 @@
// }
'use client'
import { useWallet } from '@/context/WalletContext'
import { Button } from '@workspace/ui/components/button'
import {
@ -78,36 +77,16 @@ import {
} from '@workspace/ui/components/dropdown-menu'
import { cn } from '@workspace/ui/lib/utils'
import { ChevronDown, LogOut } from 'lucide-react'
import useCheckBalance from '@/components/iframe/check-balance-iframe/useCheckBalance'
import { useEffect } from 'react'
const IFRAME_ID = 'checkBalanceIframe'
export function WalletSessionBadge({ className }: { className?: string }) {
export function WalletSessionBadge() {
const { wallet, isConnected, connect, disconnect } = useWallet()
const { isBalanceSufficient, checkBalance } = useCheckBalance("1", IFRAME_ID)
// Check balance when wallet connects
useEffect(() => {
if (isConnected) {
checkBalance()
}
}, [isConnected, checkBalance])
// Format address for display (first 6 chars + ... + last 4 chars)
// Format address for display
const formatAddress = (address?: string) => {
if (!address) return 'Connect Wallet'
return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`
}
// Determine the status indicator color based on connection and balance
const getStatusColor = () => {
if (!isConnected) return 'bg-red-500'
if (isBalanceSufficient === false) return 'bg-yellow-500'
if (isBalanceSufficient === true) return 'bg-green-500'
return 'bg-blue-500' // Checking balance
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -116,41 +95,37 @@ export function WalletSessionBadge({ className }: { className?: string }) {
className={cn(
'flex items-center space-x-2 rounded-md border px-3 py-1.5 text-sm font-medium',
'hover:bg-accent hover:text-accent-foreground',
'dark:bg-accent/5 dark:hover:bg-accent/10',
className
'dark:bg-accent/5 dark:hover:bg-accent/10'
)}
>
<span className="relative flex h-2 w-2">
<span className="relative flex h-2 w-2 mr-2">
<span
className={cn(
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
getStatusColor()
isConnected ? 'bg-green-400' : 'bg-red-400'
)}
/>
<span
className={cn(
'relative inline-flex h-2 w-2 rounded-full',
getStatusColor()
isConnected ? 'bg-green-500' : 'bg-red-500'
)}
/>
</span>
<span>
{isConnected && wallet?.address
? formatAddress(wallet.address)
: 'Connect Wallet'}
</span>
<ChevronDown className="h-4 w-4" />
<span>{isConnected && wallet?.address
? formatAddress(wallet.address)
: 'Connect Wallet'}</span>
<ChevronDown className="h-4 w-4 ml-2" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isConnected ? (
<>
<div className="px-2 py-1.5">
<p className="text-sm font-medium">Connected to:</p>
<p className="text-xs text-muted-foreground truncate">{wallet?.address}</p>
<p className="text-xs text-muted-foreground mt-1">
Balance: {isBalanceSufficient === undefined ? 'Checking...' :
isBalanceSufficient ? 'Sufficient' : 'Insufficient'}
<p className="text-sm font-medium">Connected Wallet</p>
<p className="text-xs text-muted-foreground font-mono truncate max-w-[200px]">
{wallet?.address}
</p>
</div>
<DropdownMenuItem

View File

@ -29,8 +29,8 @@
// }
'use client'
import { useWallet } from '@/context/WalletContext' // or WalletContextProvider
import { useState } from 'react'
import { useWallet } from '@/context/WalletContextProvider'
import { Button } from '@workspace/ui/components/button'
import {
DropdownMenu,
@ -41,10 +41,17 @@ import {
import { cn } from '@workspace/ui/lib/utils'
import { ChevronDown, LogOut } from 'lucide-react'
export function WalletSessionBadge({ className }: { className?: string }) {
export function WalletSessionBadge() {
const { wallet, isConnected, connect, disconnect } = useWallet()
const [showAuthModal, setShowAuthModal] = useState(false)
// Format address for display (first 6 chars + ... + last 4 chars)
const handleConnect = () => {
// Instead of directly showing the modal, call the connect function
// from WalletContext which should now use the iframe messaging
connect()
}
// Format address for display
const formatAddress = (address?: string) => {
if (!address) return 'Connect Wallet'
return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`
@ -58,8 +65,7 @@ export function WalletSessionBadge({ className }: { className?: string }) {
className={cn(
'flex items-center space-x-2 rounded-md border px-3 py-1.5 text-sm font-medium',
'hover:bg-accent hover:text-accent-foreground',
'dark:bg-accent/5 dark:hover:bg-accent/10',
className
'dark:bg-accent/5 dark:hover:bg-accent/10'
)}
>
<span className="relative flex h-2 w-2">
@ -69,6 +75,7 @@ export function WalletSessionBadge({ className }: { className?: string }) {
isConnected ? 'bg-green-400' : 'bg-red-400'
)}
/>
<span
className={cn(
'relative inline-flex h-2 w-2 rounded-full',
@ -94,7 +101,7 @@ export function WalletSessionBadge({ className }: { className?: string }) {
) : (
<DropdownMenuItem
className="flex cursor-pointer items-center"
onClick={connect}
onClick={handleConnect}
>
<span>Connect Wallet</span>
</DropdownMenuItem>

View File

@ -1,63 +1,213 @@
// 'use client'
// import { useCallback, useEffect, useState } from 'react'
// // Commenting out these imports as they cause linter errors due to missing dependencies
// // In an actual implementation, these would be properly installed
// // import { generateNonce, SiweMessage } from 'siwe'
// // import axios from 'axios'
// // Define proper types to replace 'any'
// interface SiweMessageProps {
// version: string
// domain: string
// uri: string
// chainId: number
// address: string
// nonce: string
// statement: string
// }
// interface ValidateRequestData {
// message: string
// signature: string
// }
// // Mock implementations to demonstrate functionality without dependencies
// // In a real project, use the actual dependencies
// const generateNonce = () => Math.random().toString(36).substring(2, 15)
// const SiweMessage = class {
// constructor(props: SiweMessageProps) {
// this.props = props
// }
// props: SiweMessageProps
// prepareMessage() {
// return JSON.stringify(this.props)
// }
// }
// // Access environment variables from .env.local with fallbacks for safety
// // In a production environment, these would be properly configured
// const WALLET_IFRAME_URL =
// process.env.NEXT_PUBLIC_WALLET_IFRAME_URL || 'https://wallet.example.com'
// // Mock axios implementation
// const axiosInstance = {
// post: async (url: string, data: ValidateRequestData) => {
// console.log('Mock API call to', url, 'with data', data)
// return { data: { success: true } }
// }
// }
// /**
// * AutoSignInIFrameModal component that handles wallet authentication through an iframe.
// * This component is responsible for:
// * 1. Getting the wallet address
// * 2. Creating a Sign-In With Ethereum message
// * 3. Requesting signature from the wallet
// * 4. Validating the signature with the backend
// *
// * @returns {JSX.Element} A modal with an iframe for wallet authentication
// */
// export function AutoSignInIFrameModal() {
// const [accountAddress, setAccountAddress] = useState<string>()
// // Handle sign-in response from the wallet iframe
// useEffect(() => {
// const handleSignInResponse = async (event: MessageEvent) => {
// if (event.origin !== WALLET_IFRAME_URL) return
// if (event.data.type === 'SIGN_IN_RESPONSE') {
// try {
// const response = await axiosInstance.post('/auth/validate', {
// message: event.data.data.message,
// signature: event.data.data.signature
// })
// if (response.data.success === true) {
// // In Next.js, we would use router.push instead
// window.location.href = '/'
// }
// } catch (error) {
// console.error('Error signing in:', error)
// }
// }
// }
// window.addEventListener('message', handleSignInResponse)
// return () => {
// window.removeEventListener('message', handleSignInResponse)
// }
// }, [])
// // Initiate auto sign-in when account address is available
// useEffect(() => {
// const initiateAutoSignIn = async () => {
// if (!accountAddress) return
// const iframe = document.getElementById(
// 'walletAuthFrame'
// ) as HTMLIFrameElement
// if (!iframe.contentWindow) {
// console.error('Iframe not found or not loaded')
// return
// }
// const message = new SiweMessage({
// version: '1',
// domain: window.location.host,
// uri: window.location.origin,
// chainId: 1,
// address: accountAddress,
// nonce: generateNonce(),
// statement: 'Sign in With Ethereum.'
// }).prepareMessage()
// iframe.contentWindow.postMessage(
// {
// type: 'AUTO_SIGN_IN',
// chainId: '1',
// message
// },
// WALLET_IFRAME_URL
// )
// }
// initiateAutoSignIn()
// }, [accountAddress])
// // Listen for wallet accounts data
// useEffect(() => {
// const handleAccountsDataResponse = async (event: MessageEvent) => {
// if (event.origin !== WALLET_IFRAME_URL) return
// if (
// event.data.type === 'WALLET_ACCOUNTS_DATA' &&
// event.data.data?.length > 0
// ) {
// setAccountAddress(event.data.data[0].address)
// }
// }
// window.addEventListener('message', handleAccountsDataResponse)
// return () => {
// window.removeEventListener('message', handleAccountsDataResponse)
// }
// }, [])
// // Request wallet address when iframe is loaded
// const getAddressFromWallet = useCallback(() => {
// const iframe = document.getElementById(
// 'walletAuthFrame'
// ) as HTMLIFrameElement
// if (!iframe.contentWindow) {
// console.error('Iframe not found or not loaded')
// return
// }
// iframe.contentWindow.postMessage(
// {
// type: 'REQUEST_CREATE_OR_GET_ACCOUNTS',
// chainId: '1'
// },
// WALLET_IFRAME_URL
// )
// }, [])
// return (
// <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
// <div className="relative w-[90%] max-w-6xl h-[600px] max-h-[80vh] overflow-auto rounded-lg bg-white shadow-lg">
// <iframe
// onLoad={getAddressFromWallet}
// id="walletAuthFrame"
// src={`${WALLET_IFRAME_URL}/auto-sign-in`}
// className="w-full h-full"
// sandbox="allow-scripts allow-same-origin"
// title="Wallet Authentication"
// />
// </div>
// </div>
// )
// }
// src/components/iframe/auto-sign-in/AutoSignInIFrameModal.tsx
'use client'
import { useCallback, useEffect, useState } from 'react'
// Commenting out these imports as they cause linter errors due to missing dependencies
// In an actual implementation, these would be properly installed
// import { generateNonce, SiweMessage } from 'siwe'
// import axios from 'axios'
import { generateNonce, SiweMessage } from 'siwe'
import axios from 'axios'
// Define proper types to replace 'any'
interface SiweMessageProps {
version: string
domain: string
uri: string
chainId: number
address: string
nonce: string
statement: string
const axiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
withCredentials: true
})
const WALLET_IFRAME_URL = process.env.NEXT_PUBLIC_WALLET_IFRAME_URL || 'http://localhost:4000'
interface AutoSignInProps {
onAuthComplete?: (success: boolean) => void
onClose?: () => void
}
interface ValidateRequestData {
message: string
signature: string
}
// Mock implementations to demonstrate functionality without dependencies
// In a real project, use the actual dependencies
const generateNonce = () => Math.random().toString(36).substring(2, 15)
const SiweMessage = class {
constructor(props: SiweMessageProps) {
this.props = props
}
props: SiweMessageProps
prepareMessage() {
return JSON.stringify(this.props)
}
}
// Access environment variables from .env.local with fallbacks for safety
// In a production environment, these would be properly configured
const WALLET_IFRAME_URL =
process.env.NEXT_PUBLIC_WALLET_IFRAME_URL || 'https://wallet.example.com'
// Mock axios implementation
const axiosInstance = {
post: async (url: string, data: ValidateRequestData) => {
console.log('Mock API call to', url, 'with data', data)
return { data: { success: true } }
}
}
/**
* AutoSignInIFrameModal component that handles wallet authentication through an iframe.
* This component is responsible for:
* 1. Getting the wallet address
* 2. Creating a Sign-In With Ethereum message
* 3. Requesting signature from the wallet
* 4. Validating the signature with the backend
*
* @returns {JSX.Element} A modal with an iframe for wallet authentication
*/
export function AutoSignInIFrameModal() {
export function AutoSignInIFrameModal({ onAuthComplete, onClose }: AutoSignInProps = {}) {
const [accountAddress, setAccountAddress] = useState<string>()
const [isVisible, setIsVisible] = useState(true)
const [authStatus, setAuthStatus] = useState<'idle' | 'connecting' | 'signing' | 'success' | 'error'>('idle')
// Handle sign-in response from the wallet iframe
useEffect(() => {
@ -66,26 +216,70 @@ export function AutoSignInIFrameModal() {
if (event.data.type === 'SIGN_IN_RESPONSE') {
try {
setAuthStatus('signing')
console.log('🔐 Validating SIWE signature...')
const response = await axiosInstance.post('/auth/validate', {
message: event.data.data.message,
signature: event.data.data.signature
})
if (response.data.success === true) {
// In Next.js, we would use router.push instead
window.location.href = '/'
console.log('✅ SIWE authentication successful!')
setAuthStatus('success')
// Notify parent component instead of redirecting
onAuthComplete?.(true)
// Close modal after a brief delay
setTimeout(() => {
setIsVisible(false)
onClose?.()
}, 1000)
} else {
console.error('❌ SIWE authentication failed')
setAuthStatus('error')
onAuthComplete?.(false)
}
} catch (error) {
console.error('Error signing in:', error)
console.error('❌ Error during SIWE validation:', error)
setAuthStatus('error')
onAuthComplete?.(false)
}
}
}
window.addEventListener('message', handleSignInResponse)
return () => window.removeEventListener('message', handleSignInResponse)
}, [onAuthComplete, onClose])
return () => {
window.removeEventListener('message', handleSignInResponse)
// Listen for wallet accounts data
useEffect(() => {
const handleAccountsDataResponse = async (event: MessageEvent) => {
if (event.origin !== WALLET_IFRAME_URL) return
if (event.data.type === 'WALLET_ACCOUNTS_DATA' && event.data.data?.length > 0) {
let address;
// Handle multiple data formats
if (Array.isArray(event.data.data)) {
if (typeof event.data.data[0] === 'string') {
address = event.data.data[0];
} else if (event.data.data[0] && typeof event.data.data[0].address === 'string') {
address = event.data.data[0].address;
}
}
if (address) {
console.log('📱 Got wallet address for SIWE:', address)
setAccountAddress(address)
setAuthStatus('connecting')
}
}
}
window.addEventListener('message', handleAccountsDataResponse)
return () => window.removeEventListener('message', handleAccountsDataResponse)
}, [])
// Initiate auto sign-in when account address is available
@ -93,12 +287,13 @@ export function AutoSignInIFrameModal() {
const initiateAutoSignIn = async () => {
if (!accountAddress) return
const iframe = document.getElementById(
'walletAuthFrame'
) as HTMLIFrameElement
console.log('🔐 Starting SIWE authentication for:', accountAddress)
if (!iframe.contentWindow) {
console.error('Iframe not found or not loaded')
const iframe = document.getElementById('walletAuthFrame') as HTMLIFrameElement
if (!iframe?.contentWindow) {
console.error('❌ walletAuthFrame iframe not found')
setAuthStatus('error')
return
}
@ -112,6 +307,8 @@ export function AutoSignInIFrameModal() {
statement: 'Sign in With Ethereum.'
}).prepareMessage()
console.log('📝 SIWE message created, requesting signature...')
iframe.contentWindow.postMessage(
{
type: 'AUTO_SIGN_IN',
@ -122,40 +319,23 @@ export function AutoSignInIFrameModal() {
)
}
initiateAutoSignIn()
}, [accountAddress])
// Listen for wallet accounts data
useEffect(() => {
const handleAccountsDataResponse = async (event: MessageEvent) => {
if (event.origin !== WALLET_IFRAME_URL) return
if (
event.data.type === 'WALLET_ACCOUNTS_DATA' &&
event.data.data?.length > 0
) {
setAccountAddress(event.data.data[0].address)
}
if (accountAddress && authStatus === 'connecting') {
setTimeout(initiateAutoSignIn, 500)
}
window.addEventListener('message', handleAccountsDataResponse)
return () => {
window.removeEventListener('message', handleAccountsDataResponse)
}
}, [])
}, [accountAddress, authStatus])
// Request wallet address when iframe is loaded
const getAddressFromWallet = useCallback(() => {
const iframe = document.getElementById(
'walletAuthFrame'
) as HTMLIFrameElement
const iframe = document.getElementById('walletAuthFrame') as HTMLIFrameElement
if (!iframe.contentWindow) {
console.error('Iframe not found or not loaded')
if (!iframe?.contentWindow) {
console.error('❌ Iframe not found or not loaded')
return
}
console.log('📤 Requesting wallet address for SIWE...')
setAuthStatus('idle')
iframe.contentWindow.postMessage(
{
type: 'REQUEST_CREATE_OR_GET_ACCOUNTS',
@ -165,9 +345,61 @@ export function AutoSignInIFrameModal() {
)
}, [])
// Don't render if not visible
if (!isVisible) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="relative w-[90%] max-w-6xl h-[600px] max-h-[80vh] overflow-auto rounded-lg bg-white shadow-lg">
{/* Status indicator */}
<div className="absolute top-4 left-4 z-10 bg-white/90 px-3 py-2 rounded-md shadow-sm">
<div className="flex items-center space-x-2">
{authStatus === 'idle' && (
<>
<div className="w-2 h-2 bg-gray-400 rounded-full"></div>
<span className="text-sm text-gray-600">Initializing...</span>
</>
)}
{authStatus === 'connecting' && (
<>
<div className="w-2 h-2 bg-blue-400 rounded-full animate-pulse"></div>
<span className="text-sm text-blue-600">Connecting wallet...</span>
</>
)}
{authStatus === 'signing' && (
<>
<div className="w-2 h-2 bg-yellow-400 rounded-full animate-pulse"></div>
<span className="text-sm text-yellow-600">Signing message...</span>
</>
)}
{authStatus === 'success' && (
<>
<div className="w-2 h-2 bg-green-400 rounded-full"></div>
<span className="text-sm text-green-600">Authentication complete!</span>
</>
)}
{authStatus === 'error' && (
<>
<div className="w-2 h-2 bg-red-400 rounded-full"></div>
<span className="text-sm text-red-600">Authentication failed</span>
</>
)}
</div>
</div>
{/* Close button */}
{onClose && (
<button
onClick={() => {
setIsVisible(false)
onClose()
}}
className="absolute top-4 right-4 z-10 bg-white/90 hover:bg-white px-2 py-1 rounded-md shadow-sm text-gray-600 hover:text-gray-800"
>
</button>
)}
<iframe
onLoad={getAddressFromWallet}
id="walletAuthFrame"
@ -179,4 +411,4 @@ export function AutoSignInIFrameModal() {
</div>
</div>
)
}
}

View File

@ -1,20 +1,15 @@
// src/components/iframe/check-balance-iframe/CheckBalanceIframe.tsx
'use client'
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
import { Dialog } from '@workspace/ui/components/dialog'
import { useEffect, useState } from 'react'
import useCheckBalance from './useCheckBalance'
const CHECK_BALANCE_INTERVAL = 5000
const IFRAME_ID = 'checkBalanceIframe'
const BALANCE_IFRAME_ID = 'balance-check-iframe' // Different ID to avoid conflicts
/**
* CheckBalanceIframe component that checks the balance using an iframe.
* @param {Object} props - The component props.
* @param {function} props.onBalanceChange - Callback function to be called when the balance changes.
* @param {boolean} props.isPollingEnabled - Determines whether to poll the balance periodically.
* @param {string} props.amount - The amount to check against the balance.
* @returns {JSX.Element} - The CheckBalanceIframe component.
* CheckBalanceIframe component for balance checking only.
* This is separate from wallet connection functionality.
*/
const CheckBalanceIframe = ({
onBalanceChange,
@ -27,24 +22,18 @@ const CheckBalanceIframe = ({
}) => {
const { isBalanceSufficient, checkBalance } = useCheckBalance(
amount,
IFRAME_ID
BALANCE_IFRAME_ID
)
const [isLoaded, setIsLoaded] = useState(false)
/**
* useEffect hook that calls checkBalance when the component is loaded or the amount changes.
*/
// Check balance when loaded or amount changes
useEffect(() => {
if (!isLoaded) {
return
}
if (!isLoaded) return
checkBalance()
}, [checkBalance, isLoaded])
/**
* useEffect hook that sets up an interval to poll the balance if polling is enabled.
* Clears the interval when the component unmounts, balance is sufficient, or polling is disabled.
*/
// Set up polling if enabled
useEffect(() => {
if (!isPollingEnabled || !isLoaded || isBalanceSufficient) {
return
@ -54,34 +43,34 @@ const CheckBalanceIframe = ({
checkBalance()
}, CHECK_BALANCE_INTERVAL)
return () => {
clearInterval(interval)
}
return () => clearInterval(interval)
}, [isBalanceSufficient, isPollingEnabled, checkBalance, isLoaded])
/**
* useEffect hook that calls the onBalanceChange callback when the isBalanceSufficient state changes.
*/
// Notify parent of balance changes
useEffect(() => {
onBalanceChange(isBalanceSufficient)
}, [isBalanceSufficient, onBalanceChange])
return (
<Dialog open={false}>
<VisuallyHidden>
<iframe
title="Check Balance"
onLoad={() => setIsLoaded(true)}
id={IFRAME_ID}
src={process.env.NEXT_PUBLIC_WALLET_IFRAME_URL}
width="100%"
height="100%"
sandbox="allow-scripts allow-same-origin"
className="border rounded-md shadow-sm"
/>
</VisuallyHidden>
</Dialog>
<div style={{ display: 'none' }}>
{/* This iframe is only for balance checking, not wallet connection */}
<iframe
title="Balance Checker"
onLoad={() => setIsLoaded(true)}
id={BALANCE_IFRAME_ID}
src={process.env.NEXT_PUBLIC_WALLET_IFRAME_URL}
width="1"
height="1"
sandbox="allow-scripts allow-same-origin"
style={{
position: 'absolute',
left: '-9999px',
opacity: 0,
pointerEvents: 'none'
}}
/>
</div>
)
}
export default CheckBalanceIframe
export default CheckBalanceIframe

View File

@ -1,23 +1,49 @@
// src/components/onboarding/configure-step/configure-step.tsx
'use client'
import { useState, useEffect } from 'react'
import { PlusCircle } from 'lucide-react'
import { PlusCircle, Loader2, AlertTriangle, Info } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useOnboarding } from '@/components/onboarding/useOnboarding'
import { useGQLClient } from '@/context'
import { useWallet } from '@/context/WalletContext'
import { Button } from '@workspace/ui/components/button'
import { Input } from '@workspace/ui/components/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@workspace/ui/components/select'
import { Checkbox } from '@workspace/ui/components/checkbox'
import { Label } from '@workspace/ui/components/label'
import { Alert, AlertDescription } from '@workspace/ui/components/alert'
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card'
import { Badge } from '@workspace/ui/components/badge'
import { toast } from 'sonner'
interface Deployer {
deployerLrn: string
deployerApiUrl: string
minimumPayment?: string
baseDomain: string
}
interface Organization {
id: string
name: string
slug: string
}
export function ConfigureStep() {
const { nextStep, previousStep, setFormData, formData } = useOnboarding()
const { resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false)
// Backend data
const [deployers, setDeployers] = useState<Deployer[]>([])
const [organizations, setOrganizations] = useState<Organization[]>([])
const [isLoadingDeployers, setIsLoadingDeployers] = useState(true)
const [isLoadingOrgs, setIsLoadingOrgs] = useState(true)
// Form state
const [deployOption, setDeployOption] = useState<'auction' | 'lrn'>(
formData.deploymentType as ('auction' | 'lrn') || 'auction'
formData.deploymentType as ('auction' | 'lrn') || 'lrn' // Default to LRN for simplicity
)
const [numberOfDeployers, setNumberOfDeployers] = useState<string>(
formData.deployerCount || "1"
@ -28,38 +54,115 @@ export function ConfigureStep() {
const [selectedLrn, setSelectedLrn] = useState<string>(
formData.selectedLrn || ""
)
const [selectedAccount, setSelectedAccount] = useState<string>("")
const [environments, setEnvironments] = useState<{
production: boolean,
preview: boolean,
development: boolean
}>(formData.environments || {
production: false,
preview: false,
development: false
})
const [envVars, setEnvVars] = useState<{ key: string; value: string }[]>([
{ key: '', value: '' }
const [selectedOrg, setSelectedOrg] = useState<string>(
formData.selectedOrg || ""
)
const [envVars, setEnvVars] = useState<{ key: string; value: string; environments: string[] }[]>([
{ key: '', value: '', environments: ['Production'] }
])
// Contexts
const gqlClient = useGQLClient()
const { wallet } = useWallet()
// Handle hydration mismatch by waiting for mount
useEffect(() => {
setMounted(true)
}, [])
// Fetch deployers and organizations on mount
useEffect(() => {
if (mounted) {
fetchDeployers()
fetchOrganizations()
}
}, [mounted])
// Initialize environment variables from formData if available
useEffect(() => {
if (formData.environmentVariables) {
const vars: { key: string; value: string }[] = Object.entries(formData.environmentVariables).map(
([key, value]) => ({ key, value })
)
setEnvVars(vars.length > 0 ? vars : [{ key: '', value: '' }])
if (formData.environmentVariables && Array.isArray(formData.environmentVariables)) {
setEnvVars(formData.environmentVariables.length > 0 ? formData.environmentVariables : [
{ key: '', value: '', environments: ['Production'] }
])
}
}, [formData.environmentVariables])
// Fetch deployers from backend
const fetchDeployers = async () => {
try {
setIsLoadingDeployers(true)
const deployersData = await gqlClient.getDeployers()
console.log('Available deployers:', deployersData)
setDeployers(deployersData.deployers || [])
// Auto-select first deployer if available and none selected
if (deployersData.deployers && deployersData.deployers.length > 0 && !selectedLrn) {
setSelectedLrn(deployersData.deployers[0].deployerLrn)
}
} catch (error) {
console.error('Error fetching deployers:', error)
toast.error('Failed to load deployers')
} finally {
setIsLoadingDeployers(false)
}
}
// Fetch organizations from backend
const fetchOrganizations = async () => {
try {
setIsLoadingOrgs(true)
const orgsData = await gqlClient.getOrganizations()
console.log('Available organizations:', orgsData)
setOrganizations(orgsData.organizations || [])
// Auto-select first organization if available and none selected
if (orgsData.organizations && orgsData.organizations.length > 0 && !selectedOrg) {
setSelectedOrg(orgsData.organizations[0].slug)
}
} catch (error) {
console.error('Error fetching organizations:', error)
toast.error('Failed to load organizations')
} finally {
setIsLoadingOrgs(false)
}
}
// Add an empty environment variable row
const addEnvVar = () => {
setEnvVars([...envVars, { key: '', value: '' }])
setEnvVars([...envVars, { key: '', value: '', environments: ['Production'] }])
}
// Remove environment variable row
const removeEnvVar = (index: number) => {
if (envVars.length > 1) {
setEnvVars(envVars.filter((_, i) => i !== index))
}
}
// Update environment variable
const updateEnvVar = (index: number, field: 'key' | 'value', value: string) => {
const newEnvVars = [...envVars]
newEnvVars[index][field] = value
setEnvVars(newEnvVars)
}
// Toggle environment for variable
const toggleEnvironment = (index: number, environment: string) => {
const newEnvVars = [...envVars]
const currentEnvs = newEnvVars[index].environments
if (currentEnvs.includes(environment)) {
newEnvVars[index].environments = currentEnvs.filter(env => env !== environment)
} else {
newEnvVars[index].environments = [...currentEnvs, environment]
}
// Ensure at least one environment is selected
if (newEnvVars[index].environments.length === 0) {
newEnvVars[index].environments = ['Production']
}
setEnvVars(newEnvVars)
}
// Toggle deployment option
@ -67,29 +170,36 @@ export function ConfigureStep() {
setDeployOption(option)
}
// Toggle environment checkbox
const toggleEnvironment = (env: 'production' | 'preview' | 'development') => {
setEnvironments({
...environments,
[env]: !environments[env]
})
// Get selected deployer details
const selectedDeployer = deployers.find(d => d.deployerLrn === selectedLrn)
// Validate form
const canProceed = () => {
if (deployOption === 'lrn' && !selectedLrn) return false
if (!selectedOrg) return false
if (!wallet?.address) return false
return true
}
// Handle next step
const handleNext = () => {
if (!canProceed()) {
toast.error('Please complete all required fields')
return
}
// Filter out empty environment variables
const validEnvVars = envVars.filter(env => env.key.trim() && env.value.trim())
// Save configuration to form data
setFormData({
deploymentType: deployOption,
deployerCount: numberOfDeployers,
maxPrice: maxPrice,
selectedLrn: selectedLrn,
environments: environments,
environmentVariables: envVars.reduce((acc, { key, value }) => {
if (key && value) {
acc[key] = value
}
return acc
}, {} as Record<string, string>)
selectedOrg: selectedOrg,
paymentAddress: wallet?.address,
environmentVariables: validEnvVars
})
nextStep()
@ -103,6 +213,10 @@ export function ConfigureStep() {
// Determine if dark mode is active
const isDarkMode = resolvedTheme === 'dark'
// Get deployment mode info
const isTemplateMode = formData.deploymentMode === 'template'
const selectedItem = isTemplateMode ? formData.template?.name : formData.githubRepo
return (
<div className="w-full h-full flex flex-col p-8 overflow-y-auto">
{/* Configure icon and header */}
@ -115,119 +229,250 @@ export function ConfigureStep() {
</div>
<h2 className={`text-2xl font-medium text-center mb-2 ${isDarkMode ? "text-white" : "text-zinc-900"}`}>Configure</h2>
<p className={`text-center text-zinc-500 max-w-md`}>
Set the deployer LRN for a single deployment or by creating a deployer auction for multiple deployments
Define the deployment type
</p>
</div>
<div className="max-w-xl mx-auto w-full">
{/* Deployment options */}
<div className="grid grid-cols-2 gap-2 mb-6">
<Button
variant={deployOption === 'auction' ? "default" : "outline"}
className={`py-3 ${deployOption === 'auction'
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white')
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`}
onClick={() => toggleDeployOption('auction')}
>
Create Auction
</Button>
<Button
variant={deployOption === 'lrn' ? "default" : "outline"}
className={`py-3 ${deployOption === 'lrn'
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white')
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`}
onClick={() => toggleDeployOption('lrn')}
>
Deployer LRN
</Button>
</div>
{deployOption === 'auction' ? (
<>
{/* Auction settings */}
<div className="grid grid-cols-2 gap-4 mb-6">
<div>
<Label htmlFor="deployers" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Number of Deployers
</Label>
<Select value={numberOfDeployers} onValueChange={setNumberOfDeployers}>
<SelectTrigger id="deployers" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
<SelectValue placeholder="Select number" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
</SelectContent>
</Select>
{/* Project Summary */}
<Card className="mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<Info className="h-4 w-4" />
Project Summary
</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Type:</span>
<Badge variant="secondary">{isTemplateMode ? 'Template' : 'Repository'}</Badge>
</div>
<div>
<Label htmlFor="maxPrice" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Maximum Price (aint)
</Label>
<Select value={maxPrice} onValueChange={setMaxPrice}>
<SelectTrigger id="maxPrice" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
<SelectValue placeholder="Select price" />
</SelectTrigger>
<SelectContent>
<SelectItem value="500">500</SelectItem>
<SelectItem value="1000">1000</SelectItem>
<SelectItem value="2000">2000</SelectItem>
<SelectItem value="5000">5000</SelectItem>
</SelectContent>
</Select>
<div className="flex justify-between">
<span className="text-muted-foreground">Source:</span>
<span className="font-mono text-xs">{selectedItem}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Project Name:</span>
<span>{formData.projectName}</span>
</div>
</div>
</>
</CardContent>
</Card>
{/* Organization Selection */}
<div className="mb-6">
<Label htmlFor="organization" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Organization *
</Label>
{isLoadingOrgs ? (
<div className="flex items-center justify-center p-3 border rounded-md">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm text-muted-foreground">Loading organizations...</span>
</div>
) : organizations.length === 0 ? (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
No organizations found. You need to be part of at least one organization.
</AlertDescription>
</Alert>
) : (
<Select value={selectedOrg} onValueChange={setSelectedOrg}>
<SelectTrigger id="organization" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
<SelectValue placeholder="Select organization" />
</SelectTrigger>
<SelectContent>
{organizations.map((org) => (
<SelectItem key={org.id} value={org.slug}>
{org.name} ({org.slug})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Deployment options */}
<div className="mb-6">
<Label className={`text-sm mb-3 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Deployment Type
</Label>
<div className="grid grid-cols-2 gap-2">
<Button
variant={deployOption === 'lrn' ? "default" : "outline"}
className={`py-3 ${deployOption === 'lrn'
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white')
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`}
onClick={() => toggleDeployOption('lrn')}
>
Deployer LRN
</Button>
<Button
variant={deployOption === 'auction' ? "default" : "outline"}
className={`py-3 ${deployOption === 'auction'
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white')
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`}
onClick={() => toggleDeployOption('auction')}
>
Create Auction
</Button>
</div>
</div>
{deployOption === 'lrn' ? (
/* LRN Deployment Settings */
<div className="mb-6">
<Label htmlFor="lrn" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Select Deployer LRN *
</Label>
{isLoadingDeployers ? (
<div className="flex items-center justify-center p-3 border rounded-md">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
<span className="text-sm text-muted-foreground">Loading deployers...</span>
</div>
) : deployers.length === 0 ? (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
No deployers available. Please contact support.
</AlertDescription>
</Alert>
) : (
<>
<Select value={selectedLrn} onValueChange={setSelectedLrn}>
<SelectTrigger id="lrn" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
<SelectValue placeholder="Select a deployer" />
</SelectTrigger>
<SelectContent>
{deployers.map((deployer) => (
<SelectItem key={deployer.deployerLrn} value={deployer.deployerLrn}>
<div className="flex flex-col">
<span>{deployer.deployerLrn}</span>
{deployer.minimumPayment && (
<span className="text-xs text-muted-foreground">
Min payment: {deployer.minimumPayment}
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* Deployer Details */}
{selectedDeployer && (
<div className="mt-3 p-3 bg-muted rounded-md">
<div className="text-sm space-y-1">
<div><strong>API URL:</strong> {selectedDeployer.deployerApiUrl}</div>
<div><strong>Base Domain:</strong> {selectedDeployer.baseDomain}</div>
{selectedDeployer.minimumPayment && (
<div><strong>Minimum Payment:</strong> {selectedDeployer.minimumPayment}</div>
)}
</div>
</div>
)}
</>
)}
</div>
) : (
<>
{/* LRN settings */}
<div className="mb-6">
<Label htmlFor="lrn" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Select Deployer LRN
/* Auction Settings */
<div className="grid grid-cols-2 gap-4 mb-6">
<div>
<Label htmlFor="deployers" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Number of Deployers
</Label>
<Select value={selectedLrn} onValueChange={setSelectedLrn}>
<SelectTrigger id="lrn" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
<SelectValue placeholder="Select" />
<Select value={numberOfDeployers} onValueChange={setNumberOfDeployers}>
<SelectTrigger id="deployers" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
<SelectValue placeholder="Select number" />
</SelectTrigger>
<SelectContent>
<SelectItem value="lrn-1">Deployer LRN 1</SelectItem>
<SelectItem value="lrn-2">Deployer LRN 2</SelectItem>
<SelectItem value="lrn-3">Deployer LRN 3</SelectItem>
<SelectItem value="1">1</SelectItem>
<SelectItem value="2">2</SelectItem>
<SelectItem value="3">3</SelectItem>
<SelectItem value="5">5</SelectItem>
<SelectItem value="10">10</SelectItem>
</SelectContent>
</Select>
</div>
</>
<div>
<Label htmlFor="maxPrice" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Maximum Price (aint)
</Label>
<Select value={maxPrice} onValueChange={setMaxPrice}>
<SelectTrigger id="maxPrice" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
<SelectValue placeholder="Select price" />
</SelectTrigger>
<SelectContent>
<SelectItem value="500">500</SelectItem>
<SelectItem value="1000">1000</SelectItem>
<SelectItem value="2000">2000</SelectItem>
<SelectItem value="5000">5000</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{/* Payment Address */}
<div className="mb-6">
<Label className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Payment Address
</Label>
<div className={`p-3 border rounded-md bg-muted ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}>
<div className="text-sm font-mono break-all">
{wallet?.address || 'No wallet connected'}
</div>
</div>
</div>
{/* Environment Variables */}
<div className="mb-6">
<Label className={`text-sm font-medium mb-2 block ${isDarkMode ? 'text-white' : 'text-zinc-900'}`}>Environment Variables</Label>
<Label className={`text-sm font-medium mb-2 block ${isDarkMode ? 'text-white' : 'text-zinc-900'}`}>
Environment Variables
</Label>
<div className={`border rounded-md p-4 ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}>
{envVars.map((envVar, index) => (
<div key={index} className="grid grid-cols-2 gap-2 mb-2">
<Input
placeholder="KEY"
value={envVar.key}
onChange={(e) => {
const newEnvVars = [...envVars];
newEnvVars[index].key = e.target.value;
setEnvVars(newEnvVars);
}}
className={`bg-transparent ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}
/>
<Input
placeholder="VALUE"
value={envVar.value}
onChange={(e) => {
const newEnvVars = [...envVars];
newEnvVars[index].value = e.target.value;
setEnvVars(newEnvVars);
}}
className={`bg-transparent ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}
/>
<div key={index} className="space-y-2 mb-4 pb-4 border-b border-muted last:border-b-0 last:mb-0 last:pb-0">
<div className="grid grid-cols-2 gap-2">
<Input
placeholder="KEY"
value={envVar.key}
onChange={(e) => updateEnvVar(index, 'key', e.target.value)}
className={`bg-transparent ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}
/>
<Input
placeholder="VALUE"
value={envVar.value}
onChange={(e) => updateEnvVar(index, 'value', e.target.value)}
className={`bg-transparent ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}
/>
</div>
<div className="flex items-center gap-4">
<span className="text-xs text-muted-foreground">Environments:</span>
{['Production', 'Preview', 'Development'].map((env) => (
<div key={env} className="flex items-center gap-1">
<Checkbox
id={`${index}-${env}`}
checked={envVar.environments.includes(env)}
onCheckedChange={() => toggleEnvironment(index, env)}
/>
<Label htmlFor={`${index}-${env}`} className="text-xs">
{env}
</Label>
</div>
))}
{envVars.length > 1 && (
<Button
variant="ghost"
size="sm"
onClick={() => removeEnvVar(index)}
className="ml-auto text-red-500 hover:text-red-700 h-6 px-2"
>
Remove
</Button>
)}
</div>
</div>
))}
<Button
@ -241,62 +486,6 @@ export function ConfigureStep() {
</div>
</div>
{/* Environment Tags */}
<div className="mb-6">
<Label className={`text-sm font-medium mb-2 block ${isDarkMode ? 'text-white' : 'text-zinc-900'}`}>Environment</Label>
<div className="space-y-3">
<div className="flex items-center">
<Checkbox
id="production"
checked={environments.production}
onCheckedChange={() => toggleEnvironment('production')}
className={isDarkMode ? 'border-zinc-600' : 'border-zinc-300'}
/>
<Label htmlFor="production" className={`ml-2 ${isDarkMode ? 'text-zinc-400' : 'text-zinc-600'}`}>
Production
</Label>
</div>
<div className="flex items-center">
<Checkbox
id="preview"
checked={environments.preview}
onCheckedChange={() => toggleEnvironment('preview')}
className={isDarkMode ? 'border-zinc-600' : 'border-zinc-300'}
/>
<Label htmlFor="preview" className={`ml-2 ${isDarkMode ? 'text-zinc-400' : 'text-zinc-600'}`}>
Preview
</Label>
</div>
<div className="flex items-center">
<Checkbox
id="development"
checked={environments.development}
onCheckedChange={() => toggleEnvironment('development')}
className={isDarkMode ? 'border-zinc-600' : 'border-zinc-300'}
/>
<Label htmlFor="development" className={`ml-2 ${isDarkMode ? 'text-zinc-400' : 'text-zinc-600'}`}>
Development
</Label>
</div>
</div>
</div>
{/* Account selection */}
<div className="mb-8">
<Label htmlFor="account" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
Select Account
</Label>
<Select value={selectedAccount} onValueChange={setSelectedAccount}>
<SelectTrigger id="account" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
<SelectValue placeholder="Select" />
</SelectTrigger>
<SelectContent>
<SelectItem value="account-1">Account 1</SelectItem>
<SelectItem value="account-2">Account 2</SelectItem>
</SelectContent>
</Select>
</div>
{/* Navigation buttons */}
<div className="flex justify-between items-center mt-4">
<Button
@ -310,6 +499,7 @@ export function ConfigureStep() {
variant="default"
className={`${isDarkMode ? 'bg-zinc-800' : 'bg-zinc-800'} text-white hover:bg-zinc-700`}
onClick={handleNext}
disabled={!canProceed()}
>
Next
</Button>

View File

@ -1,169 +1,449 @@
// src/components/onboarding/connect-step/connect-step.tsx
'use client'
import { useState, useEffect } from 'react'
import { Github } from 'lucide-react'
import { Github, Wallet, CheckCircle2, AlertTriangle, Loader2, ExternalLink, ChevronDown } from 'lucide-react'
import { useTheme } from 'next-themes'
import { SignIn } from '@clerk/nextjs'
import { useOnboarding } from '@/components/onboarding/useOnboarding'
import { Button } from '@workspace/ui/components/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@workspace/ui/components/select'
import { useAuthStatus } from '@/hooks/useAuthStatus'
import { useRepoData } from '@/hooks/useRepoData'
import { Button } from '@workspace/ui/components/button'
import { Card, CardContent } from '@workspace/ui/components/card'
import { Input } from '@workspace/ui/components/input'
import { Label } from '@workspace/ui/components/label'
import { Alert, AlertDescription } from '@workspace/ui/components/alert'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@workspace/ui/components/collapsible'
import { toast } from 'sonner'
import { GitHubBackendAuth } from '@/components/GitHubBackendAuth'
import { AVAILABLE_TEMPLATES, type TemplateDetail } from '@/constants/templates'
interface Repository {
id: string | number
full_name: string
html_url?: string
description?: string
}
export function ConnectStep() {
const { nextStep, setFormData, formData } = useOnboarding()
const { resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false)
// Repository vs Template selection
const [selectedRepo, setSelectedRepo] = useState<string>(formData.githubRepo || '')
const [selectedTemplate, setSelectedTemplate] = useState<TemplateDetail | undefined>(
formData.template || undefined
)
const [projectName, setProjectName] = useState<string>(formData.projectName || '')
const [isImportMode, setIsImportMode] = useState(true)
const { repoData: repositories, isLoading } = useRepoData('')
// Auth status and warning display
const [showAuthWarning, setShowAuthWarning] = useState(false)
// Auth status hook
const {
clerk,
wallet,
backend,
isFullyAuthenticated,
isReady,
missing,
progress,
connectWallet,
checkGithubBackendAuth
} = useAuthStatus()
// Repository data
const { repoData: repositories, isLoading: isLoadingRepos } = useRepoData('')
// Handle hydration mismatch by waiting for mount
useEffect(() => {
setMounted(true)
}, [])
// Auto-hide auth warning when fully authenticated
useEffect(() => {
if (isFullyAuthenticated) {
setShowAuthWarning(false)
}
}, [isFullyAuthenticated])
// Handle repository selection
const handleRepoSelect = (repo: string) => {
setSelectedRepo(repo)
setFormData({ githubRepo: repo })
setSelectedTemplate(undefined)
setFormData({
githubRepo: repo,
template: undefined,
deploymentMode: 'repository',
projectName
})
}
// Handle template selection
const handleTemplateSelect = (template: TemplateDetail) => {
setSelectedTemplate(template)
setSelectedRepo('')
// Auto-fill project name if empty
if (!projectName) {
const suggestedName = `my-${template.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`
setProjectName(suggestedName)
}
setFormData({
template: template,
githubRepo: '',
deploymentMode: 'template',
projectName: projectName || `my-${template.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`
})
}
// Handle mode toggle between import and template
const toggleMode = (mode: 'import' | 'template') => {
setIsImportMode(mode === 'import')
}
// Handle next step
const handleNext = () => {
if (selectedRepo || !isImportMode) {
nextStep()
// Clear selections when switching modes
if (mode === 'import') {
setSelectedTemplate(undefined)
setFormData({
template: undefined,
deploymentMode: 'repository',
projectName
})
} else {
setSelectedRepo('')
setFormData({
githubRepo: '',
deploymentMode: 'template',
projectName
})
}
}
// Handle project name change
const handleProjectNameChange = (value: string) => {
setProjectName(value)
setFormData({ projectName: value })
}
// Handle wallet connection
const handleConnectWallet = async () => {
try {
await connectWallet()
toast.success('Wallet connected successfully')
} catch (error) {
console.error('Wallet connection failed:', error)
toast.error('Failed to connect wallet')
}
}
// Handle GitHub backend auth status change
const handleGithubAuthChange = async (isAuthenticated: boolean) => {
await checkGithubBackendAuth()
if (isAuthenticated) {
toast.success('GitHub backend authentication completed!')
}
}
// Handle next step
const handleNext = () => {
if (!isFullyAuthenticated) {
toast.error('Please complete all authentication steps first')
setShowAuthWarning(true)
return
}
if (isImportMode && !selectedRepo) {
toast.error('Please select a repository to continue')
return
}
if (!isImportMode && (!selectedTemplate || !projectName.trim())) {
toast.error('Please select a template and enter a project name')
return
}
// For repository import, project name is optional but we'll use repo name as fallback
const finalProjectName = projectName.trim() || (isImportMode ? selectedRepo.split('/')[1] : '')
// Set final form data and proceed
setFormData({
deploymentMode: isImportMode ? 'repository' : 'template',
githubRepo: isImportMode ? selectedRepo : '',
template: !isImportMode ? selectedTemplate : undefined,
projectName: finalProjectName
})
nextStep()
}
// Don't render UI until after mount to prevent hydration mismatch
if (!mounted) {
return null
if (!mounted || !isReady) {
return (
<div className="w-full h-full flex items-center justify-center">
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
<p className="text-sm text-zinc-500">Loading authentication status...</p>
</div>
</div>
)
}
// Determine if dark mode is active
const isDarkMode = resolvedTheme === 'dark'
return (
<div className="w-full h-full flex flex-col items-center justify-center p-8">
<div className="max-w-md w-full mx-auto">
{/* Connect icon */}
<div className="mx-auto mb-6 flex justify-center">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className={isDarkMode ? "text-white" : "text-black"}>
<path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
{/* Connect header */}
<h2 className={`text-2xl font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} text-center mb-2`}>Connect</h2>
<p className="text-center text-zinc-500 mb-8">
Connect and import a GitHub repo or start from a template
</p>
{/* Git account selector */}
<div className="mb-4">
<Select defaultValue="git-account">
<SelectTrigger className={`w-full py-3 ${isDarkMode ? "border-zinc-700" : "border-zinc-300"}`}>
<div className="flex items-center">
<Github className="mr-2 h-5 w-5" />
<SelectValue placeholder="Select Git account" />
<div className="w-full h-full flex flex-col p-8 overflow-y-auto">
<div className="max-w-2xl w-full mx-auto">
{/* Header */}
<div className="text-center mb-8">
<h2 className={`text-2xl font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} mb-2`}>
Connect
</h2>
<p className="text-zinc-500 mb-6">
Connect and import a GitHub repo or start from a template
</p>
{/* GitHub Account Selector - Only show if multiple accounts */}
{clerk.user?.externalAccounts && clerk.user.externalAccounts.length > 1 && (
<div className="flex items-center justify-center mb-6">
<div className="flex items-center gap-2 px-4 py-2 bg-zinc-100 dark:bg-zinc-800 rounded-md cursor-pointer hover:bg-zinc-200 dark:hover:bg-zinc-700">
<Github className="h-4 w-4" />
<span className="text-sm font-medium">
{clerk.user?.externalAccounts?.find(acc => acc.provider === 'github')?.username || 'git-account'}
</span>
<ChevronDown className="h-4 w-4" />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="git-account">git-account</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
{/* Mode buttons */}
<div className="grid grid-cols-2 gap-2 mb-4">
{/* Authentication Warning - Only show if not fully authenticated */}
{!isFullyAuthenticated && (
<Collapsible open={showAuthWarning} onOpenChange={setShowAuthWarning}>
<CollapsibleTrigger asChild>
<Alert className="mb-6 cursor-pointer hover:bg-amber-50 dark:hover:bg-amber-950/20">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="flex items-center justify-between w-full">
<span>Authentication required to continue ({progress.completed}/{progress.total} complete)</span>
<ChevronDown className="h-4 w-4" />
</AlertDescription>
</Alert>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 mb-6">
{/* Authentication steps - same as before but in collapsible */}
{missing.clerkSignIn && (
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
<CardContent className="p-4">
<div className="flex items-center gap-3 mb-3">
<Github className="h-4 w-4" />
<span className="text-sm font-medium">Sign in with Clerk</span>
</div>
<div className="scale-90 origin-top-left">
<SignIn routing="hash" />
</div>
</CardContent>
</Card>
)}
{missing.clerkGithub && !missing.clerkSignIn && (
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
<CardContent className="p-4">
<div className="flex items-center gap-3 mb-3">
<Github className="h-4 w-4" />
<span className="text-sm font-medium">Connect GitHub Account</span>
</div>
<Button size="sm" variant="outline" onClick={() => window.open('/user-profile', '_blank')}>
<ExternalLink className="h-3 w-3 mr-2" />
Connect GitHub
</Button>
</CardContent>
</Card>
)}
{missing.walletConnection && (
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
<CardContent className="p-4">
<div className="flex items-center gap-3 mb-3">
<Wallet className="h-4 w-4" />
<span className="text-sm font-medium">Connect Wallet</span>
</div>
<Button size="sm" onClick={handleConnectWallet}>
Connect Wallet
</Button>
</CardContent>
</Card>
)}
{missing.githubBackendSync && !missing.walletConnection && !missing.clerkGithub && (
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
<CardContent className="p-4">
<div className="flex items-center gap-3 mb-3">
<Github className="h-4 w-4" />
<span className="text-sm font-medium">Sync GitHub Access</span>
</div>
<GitHubBackendAuth onAuthStatusChange={handleGithubAuthChange} />
</CardContent>
</Card>
)}
</CollapsibleContent>
</Collapsible>
)}
{/* Mode Selection Tabs */}
<div className="grid grid-cols-2 gap-1 p-1 bg-zinc-100 dark:bg-zinc-800 rounded-lg mb-6">
<Button
variant={isImportMode ? "default" : "outline"}
className={`py-3 ${isImportMode
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white')
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`}
variant={isImportMode ? "default" : "ghost"}
className={`${isImportMode
? 'bg-white dark:bg-zinc-700 shadow-sm'
: 'bg-transparent hover:bg-white/50 dark:hover:bg-zinc-700/50'
}`}
onClick={() => toggleMode('import')}
>
Import a repository
</Button>
<Button
variant={!isImportMode ? "default" : "outline"}
className={`py-3 ${!isImportMode
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white')
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`}
variant={!isImportMode ? "default" : "ghost"}
className={`${!isImportMode
? 'bg-white dark:bg-zinc-700 shadow-sm'
: 'bg-transparent hover:bg-white/50 dark:hover:bg-zinc-700/50'
}`}
onClick={() => toggleMode('template')}
>
Start with a template
</Button>
</div>
{/* Repository or template list */}
{/* Content Area */}
{isImportMode ? (
<div className={`border rounded-md overflow-hidden mb-4 ${isDarkMode ? "border-zinc-700" : "border-zinc-300"}`}>
{isLoading ? (
<div className="p-6 text-center text-zinc-500">
/* Repository Selection */
<div className="space-y-4">
{isLoadingRepos ? (
<div className="p-8 text-center text-zinc-500">
<div className="animate-spin h-5 w-5 border-2 border-zinc-500 border-t-transparent rounded-full mx-auto mb-2"></div>
Loading repositories...
</div>
) : !repositories || repositories.length === 0 ? (
<div className="p-6 text-center text-zinc-500">
No repositories found
<div className="p-8 text-center text-zinc-500">
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
No repositories found. Make sure your GitHub account has repositories.
</AlertDescription>
</Alert>
</div>
) : (
<div className="max-h-60 overflow-y-auto">
{repositories.map((repo: Repository) => (
<div
key={repo.id}
className={`flex items-center p-4 ${isDarkMode ? "border-zinc-700" : "border-zinc-200"} border-b last:border-b-0 cursor-pointer ${
selectedRepo === repo.full_name
? (isDarkMode ? 'bg-zinc-800' : 'bg-zinc-100')
: (isDarkMode ? 'hover:bg-zinc-800' : 'hover:bg-zinc-50')
}`}
onClick={() => handleRepoSelect(repo.full_name)}
>
<div className={`flex-1 ${isDarkMode ? "text-white" : "text-zinc-900"}`}>
<Github className="inline-block h-4 w-4 mr-2 text-zinc-500" />
<span>{repo.full_name}</span>
</div>
<div className="text-sm text-zinc-500">
5 minutes ago
<>
<div className="space-y-2 max-h-60 overflow-y-auto">
{repositories.map((repo: Repository) => (
<div
key={repo.id}
className={`flex items-center p-4 rounded-lg border cursor-pointer transition-all ${
selectedRepo === repo.full_name
? 'border-blue-500 bg-blue-50 dark:bg-blue-950/20'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600'
}`}
onClick={() => handleRepoSelect(repo.full_name)}
>
<Github className="h-5 w-5 mr-3 text-zinc-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{repo.full_name}</div>
{repo.description && (
<div className="text-xs text-zinc-500 truncate">{repo.description}</div>
)}
</div>
{selectedRepo === repo.full_name && (
<CheckCircle2 className="h-5 w-5 text-blue-500 flex-shrink-0" />
)}
</div>
))}
</div>
{/* Project Name Input for Repository Import */}
{selectedRepo && (
<div className="mt-6 space-y-2">
<Label htmlFor="projectName" className="text-sm font-medium">
Project Name
</Label>
<Input
id="projectName"
value={projectName}
onChange={(e) => handleProjectNameChange(e.target.value)}
placeholder="my-project-name"
className="w-full"
/>
<p className="text-xs text-zinc-500">
This will be the name of your deployment project
</p>
</div>
))}
</div>
)}
</>
)}
</div>
) : (
<div className={`border rounded-md overflow-hidden mb-4 ${isDarkMode ? "border-zinc-700" : "border-zinc-300"}`}>
<div className="p-6 text-center text-zinc-500">
Template selection coming soon
</div>
/* Template Selection */
<div className="space-y-4">
{AVAILABLE_TEMPLATES.filter(t => !t.isComingSoon).map((template) => (
<div
key={template.id}
className={`flex items-center p-4 rounded-lg border cursor-pointer transition-all ${
selectedTemplate?.id === template.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-950/20'
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600'
}`}
onClick={() => handleTemplateSelect(template)}
>
{/* Template Icon */}
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-zinc-100 dark:bg-zinc-800 mr-4">
<div className="w-6 h-6 bg-zinc-600 dark:bg-zinc-400 rounded flex items-center justify-center text-xs font-bold text-white">
{template.icon === 'web' ? 'PWA' : template.icon === 'nextjs' ? 'N' : 'IMG'}
</div>
</div>
{/* Template Info */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm mb-1">{template.name}</div>
<div className="flex items-center text-xs text-zinc-500">
<Github className="h-3 w-3 mr-1" />
{template.repoFullName}
</div>
</div>
{/* Selection Indicator */}
{selectedTemplate?.id === template.id && (
<CheckCircle2 className="h-5 w-5 text-blue-500 flex-shrink-0" />
)}
</div>
))}
{/* Project Name Input for Templates */}
{selectedTemplate && (
<div className="mt-6 space-y-2">
<Label htmlFor="projectName" className="text-sm font-medium">
Project Name
</Label>
<Input
id="projectName"
value={projectName}
onChange={(e) => handleProjectNameChange(e.target.value)}
placeholder="new-repository-name"
className="w-full"
/>
<p className="text-xs text-zinc-500">
This will be the name of your new GitHub repository
</p>
</div>
)}
</div>
)}
{/* Navigation buttons */}
{/* Navigation */}
<div className="flex justify-between items-center mt-8">
<Button
variant="outline"
className={`${isDarkMode ? "text-zinc-400 border-zinc-700" : "text-zinc-600 border-zinc-300"} bg-transparent`}
disabled={true}
>
<Button variant="outline" disabled>
Previous
</Button>
<Button
className={`${isDarkMode ? 'bg-zinc-800' : 'bg-zinc-800'} text-white hover:bg-zinc-700`}
onClick={handleNext}
disabled={!selectedRepo && isImportMode}
disabled={!isFullyAuthenticated || (isImportMode ? !selectedRepo : (!selectedTemplate || !projectName.trim()))}
>
Next
</Button>

View File

@ -1,12 +1,21 @@
// src/components/onboarding/deploy-step/deploy-step.tsx
'use client'
import { useState, useEffect } from 'react'
import { useTheme } from 'next-themes'
import { Github, Loader2 } from 'lucide-react'
import { Github, Loader2, AlertTriangle, CheckCircle2 } from 'lucide-react'
import { useOnboarding } from '@/components/onboarding/useOnboarding'
import { useGQLClient } from '@/context'
import { useWallet } from '@/context/WalletContext'
import { useDeployment } from '@/hooks/useDeployment'
import { useTemplateDeployment } from '@/hooks/useTemplate'
import { Button } from '@workspace/ui/components/button'
import { Progress } from '@workspace/ui/components/progress'
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogFooter } from '@workspace/ui/components/dialog'
import { Alert, AlertDescription } from '@workspace/ui/components/alert'
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card'
import { Badge } from '@workspace/ui/components/badge'
import { toast } from 'sonner'
export function DeployStep() {
const { previousStep, nextStep, formData, setFormData } = useOnboarding()
@ -14,21 +23,67 @@ export function DeployStep() {
const [mounted, setMounted] = useState(false)
// State
const [isDeploying, setIsDeploying] = useState(false)
const [deploymentProgress, setDeploymentProgress] = useState(0)
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
const [deploymentError, setDeploymentError] = useState<string | null>(null)
const [deploymentSuccess, setDeploymentSuccess] = useState(false)
// Contexts and hooks
const { wallet } = useWallet()
const { deployRepository, isDeploying: isRepoDeploying } = useDeployment()
const { deployTemplate, isDeploying: isTemplateDeploying } = useTemplateDeployment()
// Determine deployment type and get the right deploying state
const isTemplateMode = formData.deploymentMode === 'template'
const isDeploying = isTemplateMode ? isTemplateDeploying : isRepoDeploying
// Handle hydration mismatch by waiting for mount
useEffect(() => {
setMounted(true)
}, [])
// Repository information from previous steps
const repoFullName = formData.githubRepo || 'git-account/repo-name'
const branch = 'main'
// Get deployment info
const getDeploymentInfo = () => {
if (isTemplateMode) {
return {
name: formData.template?.name || 'Template Project',
source: formData.template?.repoFullName || 'Unknown Template',
projectName: formData.projectName || 'New Project',
type: 'Template'
}
} else {
return {
name: formData.githubRepo?.split('/').pop() || 'Repository',
source: formData.githubRepo || 'Unknown Repository',
projectName: formData.projectName || formData.githubRepo?.split('/').pop() || 'New Project',
type: 'Repository'
}
}
}
const deploymentInfo = getDeploymentInfo()
// Open the confirmation modal
const handlePayAndDeploy = () => {
if (!wallet?.address) {
toast.error('Wallet not connected')
return
}
if (!formData.selectedOrg) {
toast.error('No organization selected')
return
}
if (isTemplateMode && (!formData.template || !formData.projectName)) {
toast.error('Template or project name missing')
return
}
if (!isTemplateMode && !formData.githubRepo) {
toast.error('Repository not selected')
return
}
setShowConfirmDialog(true)
}
@ -38,42 +93,91 @@ export function DeployStep() {
}
// Handle confirmed deployment
const handleConfirmDeploy = () => {
const handleConfirmDeploy = async () => {
setShowConfirmDialog(false)
startDeployment()
setDeploymentError(null)
setDeploymentSuccess(false)
try {
if (isTemplateMode) {
await deployTemplateProject()
} else {
await deployRepositoryProject()
}
} catch (error) {
console.error('Deployment failed:', error)
setDeploymentError(error instanceof Error ? error.message : 'Deployment failed')
}
}
// Start the deployment process
const startDeployment = () => {
setIsDeploying(true)
// Deploy template project
const deployTemplateProject = async () => {
if (!formData.template || !formData.projectName || !formData.selectedOrg) {
throw new Error('Missing required template deployment data')
}
// Simulate deployment process with progress updates
let progress = 0
const interval = setInterval(() => {
progress += 10
setDeploymentProgress(progress)
if (progress >= 100) {
clearInterval(interval)
// Generate deployment ID and create URL
const deploymentId = `deploy-${Math.random().toString(36).substring(2, 9)}`
const repoName = repoFullName.split('/').pop() || 'app'
const projectId = `proj-${Math.random().toString(36).substring(2, 9)}`
// Save deployment info
setFormData({
deploymentId,
deploymentUrl: `https://${repoName}.laconic.deploy`,
projectId
})
// Move to success step after short delay
setTimeout(() => {
nextStep()
}, 500)
}
}, 500)
const config = {
template: formData.template,
projectName: formData.projectName,
organizationSlug: formData.selectedOrg,
environmentVariables: formData.environmentVariables || [],
deployerLrn: formData.selectedLrn
}
console.log('Deploying template with config:', config)
const result = await deployTemplate(config)
// Save deployment results
setFormData({
deploymentId: result.deploymentId,
deploymentUrl: result.deploymentUrl,
projectId: result.projectId,
repositoryUrl: result.repositoryUrl
})
setDeploymentSuccess(true)
toast.success('Template deployed successfully!')
// Move to success step after short delay
setTimeout(() => {
nextStep()
}, 2000)
}
// Deploy repository project
const deployRepositoryProject = async () => {
if (!formData.githubRepo || !formData.selectedOrg) {
throw new Error('Missing required repository deployment data')
}
const config = {
projectId: '', // Will be generated by backend
organizationSlug: formData.selectedOrg,
repository: formData.githubRepo,
branch: 'main', // Default branch
name: formData.projectName || formData.githubRepo.split('/').pop() || 'New Project',
environmentVariables: formData.environmentVariables || []
}
console.log('Deploying repository with config:', config)
const result = await deployRepository(config)
// Save deployment results
setFormData({
deploymentId: result.id,
deploymentUrl: result.url,
projectId: result.id
})
setDeploymentSuccess(true)
toast.success('Repository deployed successfully!')
// Move to success step after short delay
setTimeout(() => {
nextStep()
}, 2000)
}
// Don't render UI until after mount to prevent hydration mismatch
@ -101,41 +205,92 @@ export function DeployStep() {
</div>
{/* Deploy header */}
<h2 className={`text-2xl font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} text-center mb-2`}>Deploy</h2>
<h2 className={`text-2xl font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} text-center mb-2`}>
{isDeploying ? 'Deploying...' : 'Deploy'}
</h2>
<p className="text-center text-zinc-500 mb-8">
Your deployment is configured and ready to go!
{isDeploying
? 'Your project is being deployed. This may take a few minutes.'
: 'Review and confirm deployment'}
</p>
{/* Repository info */}
<div className={`border rounded-lg overflow-hidden mb-8 ${isDarkMode ? "border-zinc-700" : "border-zinc-300"}`}>
<div className="p-5 flex items-center">
<div className="mr-3">
<Github className="h-5 w-5 text-zinc-500" />
</div>
<div className="flex-1">
<div className={isDarkMode ? "text-white" : "text-zinc-900"}>{repoFullName}</div>
<div className="text-sm text-zinc-500">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" className="inline-block mr-1">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
{branch}
{/* Deployment Summary */}
<Card className="mb-6">
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center justify-between">
Deployment Summary
<Badge variant="secondary">{deploymentInfo.type}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="pt-0 space-y-3">
<div className="flex items-center gap-3">
<Github className="h-4 w-4 text-muted-foreground" />
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{deploymentInfo.projectName}</div>
<div className="text-xs text-muted-foreground font-mono">
{deploymentInfo.source}
</div>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-2 text-xs">
<div>
<div className="text-muted-foreground">Organization</div>
<div className="font-medium">{formData.selectedOrg}</div>
</div>
<div>
<div className="text-muted-foreground">Deployer</div>
<div className="font-medium">{formData.selectedLrn ? 'LRN' : 'Auction'}</div>
</div>
</div>
{formData.environmentVariables && formData.environmentVariables.length > 0 && (
<div className="pt-2">
<div className="text-xs text-muted-foreground mb-1">Environment Variables</div>
<div className="text-xs">
{formData.environmentVariables.length} variable{formData.environmentVariables.length !== 1 ? 's' : ''} configured
</div>
</div>
)}
</CardContent>
</Card>
{/* Deployment progress */}
{isDeploying && deploymentProgress > 0 && (
{/* Error Display */}
{deploymentError && (
<Alert className="mb-6" variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<div className="font-medium">Deployment Failed</div>
<div className="text-sm mt-1">{deploymentError}</div>
</AlertDescription>
</Alert>
)}
{/* Success Display */}
{deploymentSuccess && (
<Alert className="mb-6">
<CheckCircle2 className="h-4 w-4" />
<AlertDescription>
<div className="font-medium">Deployment Successful!</div>
<div className="text-sm mt-1">
Your project has been deployed successfully. You'll be redirected to the project dashboard.
</div>
</AlertDescription>
</Alert>
)}
{/* Deployment Progress - Only show while deploying */}
{isDeploying && (
<div className="mb-8">
<div className="flex justify-between items-center mb-2">
<div className={`${isDarkMode ? "text-white" : "text-zinc-900"} text-sm`}>
{deploymentProgress < 30 && "Preparing deployment..."}
{deploymentProgress >= 30 && deploymentProgress < 90 && "Deploying your project..."}
{deploymentProgress >= 90 && "Finalizing deployment..."}
{isTemplateMode ? 'Creating repository from template...' : 'Deploying repository...'}
</div>
<div className="text-zinc-500 text-xs">{deploymentProgress}%</div>
</div>
<Progress value={deploymentProgress} className={`h-1 ${isDarkMode ? "bg-zinc-800" : "bg-zinc-200"}`} />
<Progress value={undefined} className={`h-2 ${isDarkMode ? "bg-zinc-800" : "bg-zinc-200"}`} />
<div className="text-xs text-muted-foreground mt-2">
This process may take several minutes. Please do not close this window.
</div>
</div>
)}
@ -145,12 +300,20 @@ export function DeployStep() {
variant="outline"
className={`${isDarkMode ? "text-zinc-400 border-zinc-700" : "text-zinc-600 border-zinc-300"} bg-transparent`}
onClick={previousStep}
disabled={isDeploying}
disabled={isDeploying || deploymentSuccess}
>
Previous
</Button>
{isDeploying ? (
{deploymentSuccess ? (
<Button
className="bg-green-600 hover:bg-green-700 text-white"
onClick={nextStep}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
Continue
</Button>
) : isDeploying ? (
<Button
className={`${isDarkMode ? "bg-zinc-700 text-zinc-300" : "bg-zinc-300 text-zinc-600"}`}
disabled
@ -162,8 +325,9 @@ export function DeployStep() {
<Button
className="bg-blue-600 hover:bg-blue-700 text-white flex items-center"
onClick={handlePayAndDeploy}
disabled={deploymentError !== null}
>
Pay and Deploy
{formData.deploymentType === 'auction' ? 'Pay and Deploy' : 'Deploy'}
<svg className="ml-2 h-4 w-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12H19M19 12L13 6M19 12L13 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
@ -175,69 +339,79 @@ export function DeployStep() {
{/* Transaction Confirmation Dialog */}
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent className="bg-black border-zinc-800 text-white max-w-md">
<DialogTitle className="text-white">Confirm Transaction</DialogTitle>
<DialogDescription className="text-zinc-400">
This is a dialog description.
<DialogContent className="bg-background border max-w-md">
<DialogTitle>Confirm Deployment</DialogTitle>
<DialogDescription>
Review the deployment details before proceeding.
</DialogDescription>
<div className="space-y-6 py-4">
{/* From */}
<div className="space-y-4 py-4">
{/* Project Info */}
<div className="space-y-2">
<h3 className="text-lg font-medium text-white">From</h3>
<div className="space-y-1">
<div className="text-sm text-zinc-400">Address</div>
<div className="text-sm text-white break-all font-mono">laconic1sdfjwel4jfkasfjgjal45ioasjj5jjlajfjj355</div>
</div>
<div className="space-y-1">
<div className="text-sm text-zinc-400">Public Key</div>
<div className="text-sm text-white break-all font-mono">laconic1sdfjwel4jfkasfjgjal45ioasjj5jjlajfjj355</div>
</div>
<div className="space-y-1">
<div className="text-sm text-zinc-400">HD Path</div>
<div className="text-sm text-white font-mono">m/44/118/0/0/0</div>
<h3 className="text-sm font-medium">Project Details</h3>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Name:</span>
<span>{deploymentInfo.projectName}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Type:</span>
<span>{deploymentInfo.type}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Source:</span>
<span className="font-mono text-xs">{deploymentInfo.source}</span>
</div>
</div>
</div>
{/* Balance */}
<div className="space-y-1">
<div className="text-lg font-medium text-white">Balance</div>
<div className="text-lg text-white">129600</div>
</div>
{/* To */}
<div className="space-y-1">
<div className="text-lg font-medium text-white">To</div>
<div className="text-sm text-white break-all font-mono">laconic1sdfjwel4jfkasfjgjal45ioasjj5jjlajfjj355</div>
</div>
{/* Amount */}
<div className="space-y-1">
<div className="text-lg font-medium text-white">Amount</div>
<div className="space-y-1">
<div className="text-sm text-zinc-400">Balance (aint)</div>
<div className="text-sm text-white">129600</div>
</div>
<div className="space-y-1">
<div className="text-sm text-zinc-400">Amount (aint)</div>
<div className="text-sm text-white">3000</div>
{/* Wallet Info */}
<div className="space-y-2">
<h3 className="text-sm font-medium">Payment Address</h3>
<div className="p-2 bg-muted rounded text-xs font-mono break-all">
{wallet?.address}
</div>
</div>
{/* Deployer Info */}
{formData.selectedLrn && (
<div className="space-y-2">
<h3 className="text-sm font-medium">Deployer</h3>
<div className="text-sm">
<div className="font-mono text-xs">{formData.selectedLrn}</div>
</div>
</div>
)}
{/* Cost Info */}
{formData.deploymentType === 'auction' && (
<div className="space-y-2">
<h3 className="text-sm font-medium">Auction Details</h3>
<div className="space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Max Price:</span>
<span>{formData.maxPrice} aint</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Deployers:</span>
<span>{formData.deployerCount}</span>
</div>
</div>
</div>
)}
</div>
<DialogFooter className="flex justify-end space-x-2">
<Button
variant="outline"
className="text-zinc-400 bg-zinc-900 border-zinc-800 hover:bg-zinc-800"
variant="outline"
onClick={handleCancelConfirm}
>
No, cancel
Cancel
</Button>
<Button
className="bg-white text-black hover:bg-white/90"
onClick={handleConfirmDeploy}
>
Yes, confirm
Confirm Deployment
</Button>
</DialogFooter>
</DialogContent>

View File

@ -6,7 +6,6 @@ import { useTheme } from 'next-themes'
import { CheckCircle } from 'lucide-react'
import { Button } from '@workspace/ui/components/button'
import { useOnboarding } from '@/components/onboarding/useOnboarding'
import { toast } from 'sonner'
export function SuccessStep() {
const router = useRouter()
@ -22,20 +21,30 @@ export function SuccessStep() {
}, [])
// Get deployment info from form data
const repoName = formData.githubRepo ? formData.githubRepo.split('/').pop() : 'blogapp'
const repoName = formData.githubRepo ? formData.githubRepo.split('/').pop() : (formData.projectName || 'project')
const deploymentUrl = formData.deploymentUrl || `https://${repoName}.laconic.deploy`
const projectId = formData.projectId || 'unknown-id'
// Function to copy URL to clipboard
// Handle "Visit Site" button
// Handle "View Project" button - navigates to project page
const handleViewProject = () => {
console.log('Navigating to project with ID:', projectId)
resetOnboarding() // Reset state for next time
// Navigate to the project detail page using the GraphQL project ID
router.push(`/projects/${providerParam}/ps/${projectId}`)
}
// Auto-redirect after a delay (optional)
useEffect(() => {
if (mounted && projectId && projectId !== 'unknown-id') {
const timer = setTimeout(() => {
handleViewProject()
}, 3000) // Auto-redirect after 3 seconds
return () => clearTimeout(timer)
}
}, [mounted, projectId])
// Don't render UI until after mount to prevent hydration mismatch
if (!mounted) {
return null
@ -54,12 +63,30 @@ export function SuccessStep() {
{/* Success header */}
<h2 className={`text-2xl font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} text-center mb-2`}>
Successfully
Successfully Deployed!
</h2>
<p className="text-center text-zinc-500 mb-8">
Your auction was successfully created
Your project has been deployed successfully
</p>
{/* Deployment summary */}
<div className={`border rounded-md p-4 mb-6 ${isDarkMode ? "border-zinc-700" : "border-zinc-300"}`}>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Project:</span>
<span className="font-medium">{repoName}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">URL:</span>
<span className="font-mono text-xs">{deploymentUrl}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Project ID:</span>
<span className="font-mono text-xs">{projectId}</span>
</div>
</div>
</div>
{/* Next steps section */}
<div className="mb-8">
<h3 className={`text-lg font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} mb-4`}>Next steps</h3>
@ -84,12 +111,20 @@ export function SuccessStep() {
<Button
className="w-full bg-white hover:bg-white/90 text-black flex items-center justify-center"
onClick={handleViewProject}
disabled={!projectId || projectId === 'unknown-id'}
>
View Project
<svg className="ml-2 h-4 w-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12H19M19 12L13 6M19 12L13 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</Button>
{/* Manual navigation button if auto-redirect fails */}
{projectId === 'unknown-id' && (
<p className="text-center text-xs text-muted-foreground">
Project ID not found. Please navigate to projects manually.
</p>
)}
</div>
</div>
</div>

View File

@ -1,4 +1,6 @@
// FixedProjectCard.tsx - With original components
// FixedProjectCard.tsx - With fixed navigation
'use client';
import { useRouter } from 'next/navigation'
import React, { type ComponentPropsWithoutRef, useCallback } from 'react'
import { Card, CardContent, CardHeader } from '@workspace/ui/components/card'
@ -9,7 +11,6 @@ import {
AvatarFallback,
AvatarImage
} from '@workspace/ui/components/avatar'
import router from 'next/router'
/**
* Status types for project deployment status
@ -92,6 +93,15 @@ const ProjectCardActions = ({
setMenuOpen(!menuOpen);
};
// Close menu when clicking outside
React.useEffect(() => {
const handleClickOutside = () => setMenuOpen(false);
if (menuOpen) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
}, [menuOpen]);
return (
<div className="relative">
<button
@ -123,12 +133,6 @@ const ProjectCardActions = ({
);
};
function startDeployment(project: { project: any }) {
// Store selected project in state or localStorage
// Navigate to configuration page
router.push(`/projects/github/ps/create/cr/configure?repo=${project.id}`);
}
/**
* FixedProjectCard component
*/
@ -155,19 +159,21 @@ export const FixedProjectCard = ({
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
router.push(`/projects/github/ps/${project.id}/settings`);
// Navigate to the settings tab - using the correct path structure
router.push(`/projects/github/ps/${project.id}/set`);
},
[project.id, router]
);
/**
* Handles click on the delete menu item
* Handles click on the delete menu item - navigates to settings with delete intent
*/
const handleDeleteClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
router.push(`/projects/github/ps/${project.id}/settings`);
// Navigate to settings and add a query parameter to trigger delete modal
router.push(`/projects/github/ps/${project.id}/set?action=delete`);
},
[project.id, router]
);
@ -215,15 +221,6 @@ export const FixedProjectCard = ({
<div className="flex items-center gap-2">
<ProjectStatusDot status={status} />
<ProjectDeploymentInfo project={project} />
{/* <div className="mt-4 flex justify-end">
<Button
onClick={() => startDeployment(project)}
variant="default"
size="sm"
>
Deploy
</Button>
</div> */}
</div>
</CardContent>
</Card>

View File

@ -1,10 +1,12 @@
// src/components/providers.tsx
'use client'
import { ThemeProvider } from 'next-themes'
import { useEffect, useState } from 'react'
import '@workspace/ui/globals.css'
import { Toaster } from 'sonner'
import { OctokitProviderWithRouter } from '@/context/OctokitProviderWithRouter'
import { WalletProvider } from '@/context/WalletContext'
import { WalletContextProvider } from '@/context/WalletContextProvider'
import { BackendProvider } from '@/context/BackendContext'
import { GQLClientProvider } from '@/context'
import { GQLClient } from '@workspace/gql-client'
@ -16,10 +18,8 @@ export function Providers({ children }: { children: React.ReactNode }) {
// Initialize GQLClient
const initGQLClient = async () => {
try {
// Create a new instance of GQLClient
const client = new GQLClient({
endpoint: process.env.NEXT_PUBLIC_GQL_ENDPOINT || 'https://api.snowballtools-base.example',
// Add any auth headers or other configuration needed
gqlEndpoint: 'http://localhost:8000/graphql',
})
setGqlClient(client)
} catch (error) {
@ -32,8 +32,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
initGQLClient()
}, [])
if (isLoading) {
// You might want to add a loading indicator here
if (isLoading || !gqlClient) {
return <div>Loading...</div>
}
@ -45,18 +44,16 @@ export function Providers({ children }: { children: React.ReactNode }) {
disableTransitionOnChange
enableColorScheme
>
<>
<Toaster />
<WalletProvider>
<Toaster />
<WalletContextProvider>
<BackendProvider>
<GQLClientProvider client={gqlClient}>
<OctokitProviderWithRouter>
{gqlClient && (
<GQLClientProvider client={gqlClient}>
{children}
</GQLClientProvider>
)}
{children}
</OctokitProviderWithRouter>
</WalletProvider>
</>
</GQLClientProvider>
</BackendProvider>
</WalletContextProvider>
</ThemeProvider>
)
}
}

View File

@ -0,0 +1,114 @@
// src/components/templates/TemplateCard.tsx
'use client'
import { ArrowRight, Clock, CheckCircle2 } from 'lucide-react'
import { Button } from '@workspace/ui/components/button'
import { Badge } from '@workspace/ui/components/badge'
import { cn } from '@workspace/ui/lib/utils'
import { TemplateIcon, type TemplateIconType } from './TemplateIcon'
import type { TemplateDetail } from '@/constants/templates'
interface TemplateCardProps {
template: TemplateDetail
isSelected?: boolean
onSelect?: (template: TemplateDetail) => void
disabled?: boolean
className?: string
}
export function TemplateCard({
template,
isSelected = false,
onSelect,
disabled = false,
className
}: TemplateCardProps) {
const handleClick = () => {
if (disabled || template.isComingSoon) return
onSelect?.(template)
}
return (
<div
className={cn(
'group relative flex items-start gap-4 p-4 rounded-lg border transition-all cursor-pointer',
'hover:border-primary/50 hover:bg-accent/50',
isSelected && 'border-primary bg-primary/5',
(disabled || template.isComingSoon) && 'opacity-50 cursor-not-allowed',
className
)}
onClick={handleClick}
>
{/* Icon */}
<div className={cn(
'flex items-center justify-center w-12 h-12 rounded-lg border',
'bg-background shadow-sm',
isSelected && 'border-primary bg-primary/10'
)}>
<TemplateIcon
type={template.icon as TemplateIconType}
size={24}
className={isSelected ? 'text-primary' : 'text-muted-foreground'}
/>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between mb-2">
<h3 className="font-medium text-sm leading-tight">
{template.name}
</h3>
{/* Selection indicator */}
{isSelected && (
<CheckCircle2 className="h-5 w-5 text-primary flex-shrink-0" />
)}
</div>
<p className="text-xs text-muted-foreground leading-relaxed mb-3">
{template.description}
</p>
{/* Tags */}
{template.tags && template.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{template.tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className="text-xs px-2 py-0.5"
>
{tag}
</Badge>
))}
</div>
)}
{/* Footer */}
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground font-mono">
{template.repoFullName}
</span>
{template.isComingSoon ? (
<Badge variant="outline" className="text-xs">
<Clock className="w-3 h-3 mr-1" />
Coming Soon
</Badge>
) : (
<Button
variant="ghost"
size="sm"
className={cn(
'h-auto p-1 opacity-0 group-hover:opacity-100 transition-opacity',
isSelected && 'opacity-100'
)}
>
<ArrowRight className="h-3 w-3" />
</Button>
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,37 @@
// src/components/templates/TemplateIcon.tsx
'use client'
import {
Globe,
Image,
Code,
Smartphone,
Layout} from 'lucide-react'
export type TemplateIconType = 'web' | 'image' | 'nextjs' | 'pwa' | 'code' | 'mobile' | 'layout'
interface TemplateIconProps {
type: TemplateIconType
size?: number
className?: string
}
const iconMap = {
web: Globe,
image: Image,
nextjs: Code,
pwa: Smartphone,
code: Code,
mobile: Smartphone,
layout: Layout,
}
export function TemplateIcon({ type, size = 24, className = '' }: TemplateIconProps) {
const IconComponent = iconMap[type] || Code
return (
<div className={`flex items-center justify-center ${className}`}>
<IconComponent size={size} />
</div>
)
}

View File

@ -0,0 +1,109 @@
// src/components/templates/TemplateSelection.tsx
'use client'
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card'
import { Alert, AlertDescription } from '@workspace/ui/components/alert'
import { Badge } from '@workspace/ui/components/badge'
import { Info, Sparkles } from 'lucide-react'
import { TemplateCard } from './TemplateCard'
import { AVAILABLE_TEMPLATES, type TemplateDetail } from '@/constants/templates'
interface TemplateSelectionProps {
selectedTemplate?: TemplateDetail
onTemplateSelect: (template: TemplateDetail) => void
disabled?: boolean
className?: string
}
export function TemplateSelection({
selectedTemplate,
onTemplateSelect,
disabled = false,
className
}: TemplateSelectionProps) {
const [hoveredTemplate, setHoveredTemplate] = useState<string | null>(null)
const availableTemplates = AVAILABLE_TEMPLATES.filter(t => !t.isComingSoon)
const comingSoonTemplates = AVAILABLE_TEMPLATES.filter(t => t.isComingSoon)
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Sparkles className="h-5 w-5 text-primary" />
Choose a Template
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<Info className="h-4 w-4" />
<AlertDescription className="text-sm">
Templates create a new repository in your GitHub account with pre-configured code.
You can customize it after deployment.
</AlertDescription>
</Alert>
{/* Available Templates */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">Available Templates</h3>
<Badge variant="secondary" className="text-xs">
{availableTemplates.length} templates
</Badge>
</div>
<div className="grid gap-3">
{availableTemplates.map((template) => (
<TemplateCard
key={template.id}
template={template}
isSelected={selectedTemplate?.id === template.id}
onSelect={onTemplateSelect}
disabled={disabled}
/>
))}
</div>
</div>
{/* Coming Soon Templates */}
{comingSoonTemplates.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-muted-foreground">Coming Soon</h3>
<Badge variant="outline" className="text-xs">
{comingSoonTemplates.length} more
</Badge>
</div>
<div className="grid gap-3">
{comingSoonTemplates.map((template) => (
<TemplateCard
key={template.id}
template={template}
disabled={true}
/>
))}
</div>
</div>
)}
{/* Selection Summary */}
{selectedTemplate && (
<div className="p-3 bg-primary/5 border border-primary/20 rounded-md">
<div className="flex items-center gap-2 mb-1">
<div className="w-2 h-2 bg-primary rounded-full"></div>
<span className="text-sm font-medium">Selected Template</span>
</div>
<p className="text-sm text-muted-foreground">
<strong>{selectedTemplate.name}</strong> will be forked to your GitHub account
</p>
<p className="text-xs text-muted-foreground mt-1 font-mono">
Source: {selectedTemplate.repoFullName}
</p>
</div>
)}
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,46 @@
// src/constants/templates.ts
export const TEMPLATE_REPOS = {
PWA: process.env.NEXT_PUBLIC_GITHUB_PWA_TEMPLATE_REPO || 'snowball-test/test-progressive-web-app',
IMAGE_UPLOAD_PWA: process.env.NEXT_PUBLIC_GITHUB_IMAGE_UPLOAD_PWA_TEMPLATE_REPO || 'snowball-test/image-upload-pwa-example',
NEXTJS: process.env.NEXT_PUBLIC_GITHUB_NEXT_APP_TEMPLATE_REPO || 'snowball-test/starter.nextjs-react-tailwind',
}
export interface TemplateDetail {
id: string
name: string
icon: string
repoFullName: string
description: string
isComingSoon?: boolean
tags?: string[]
}
export const AVAILABLE_TEMPLATES: TemplateDetail[] = [
{
id: 'pwa',
name: 'Progressive Web App (PWA)',
icon: 'web',
repoFullName: TEMPLATE_REPOS.PWA,
description: 'A fast, offline-capable web application with service worker support',
tags: ['PWA', 'Service Worker', 'Offline'],
isComingSoon: false,
},
{
id: 'image-upload-pwa',
name: 'Image Upload PWA',
icon: 'image',
repoFullName: TEMPLATE_REPOS.IMAGE_UPLOAD_PWA,
description: 'PWA with image upload and processing capabilities',
tags: ['PWA', 'Upload', 'Images'],
isComingSoon: false,
},
{
id: 'nextjs-tailwind',
name: 'Next.js + React + TailwindCSS',
icon: 'nextjs',
repoFullName: TEMPLATE_REPOS.NEXTJS,
description: 'Modern React framework with TailwindCSS for styling',
tags: ['Next.js', 'React', 'TailwindCSS'],
isComingSoon: false,
},
]

View File

@ -0,0 +1,111 @@
// src/context/BackendContext.tsx
'use client'
import type React from 'react'
import {
type ReactNode,
createContext,
useContext,
useEffect,
useState,
useCallback
} from 'react'
/**
* @interface BackendContextType
* @description Defines the structure of the BackendContext value.
*/
interface BackendContextType {
// Connection status
isBackendConnected: boolean
isLoading: boolean
// Actions
checkBackendConnection: () => Promise<void>
refreshStatus: () => Promise<void>
}
/**
* @const BackendContext
* @description Creates a context for managing backend connection.
*/
const BackendContext = createContext<BackendContextType | undefined>(undefined)
/**
* @component BackendProvider
* @description Provides the BackendContext to its children.
*/
export const BackendProvider: React.FC<{ children: ReactNode }> = ({
children
}) => {
// State
const [isBackendConnected, setIsBackendConnected] = useState(false)
const [isLoading, setIsLoading] = useState(true)
// Check backend connection
const checkBackendConnection = useCallback(async () => {
try {
const response = await fetch('http://localhost:8000/auth/session', {
method: 'GET',
credentials: 'include',
})
const connected = response.ok
setIsBackendConnected(connected)
if (connected) {
console.log('✅ Backend connected')
} else {
console.log('❌ Backend not connected')
}
return connected
} catch (error) {
console.error('Error checking backend connection:', error)
setIsBackendConnected(false)
return false
}
}, [])
// Refresh backend status
const refreshStatus = useCallback(async () => {
setIsLoading(true)
try {
await checkBackendConnection()
} finally {
setIsLoading(false)
}
}, [checkBackendConnection])
// Initialize on mount
useEffect(() => {
refreshStatus()
}, [refreshStatus])
return (
<BackendContext.Provider
value={{
isBackendConnected,
isLoading,
checkBackendConnection,
refreshStatus
}}
>
{children}
</BackendContext.Provider>
)
}
/**
* @function useBackend
* @description A hook that provides access to the BackendContext.
* @returns {BackendContextType} The backend context value.
* @throws {Error} If used outside of a BackendProvider.
*/
export const useBackend = () => {
const context = useContext(BackendContext)
if (context === undefined) {
throw new Error('useBackend must be used within a BackendProvider')
}
return context
}

View File

@ -1,3 +1,4 @@
// src/context/OctokitContext.tsx
import { Octokit, RequestError } from 'octokit'
import { useDebounceCallback } from 'usehooks-ts'
@ -55,21 +56,40 @@ export const OctokitProvider = ({
}) => {
const [authToken, setAuthToken] = useState<string | null>(null)
const [isAuth, setIsAuth] = useState(false)
// const navigate = externalNavigate || internalNavigateconst
const router = useRouter()
const { orgSlug } = useParams()
// const { toast, dismiss } = useToast()
const client = useGQLClient()
const gqlClient = useGQLClient()
/**
* @function fetchUser
* @description Fetches the user's GitHub token from the GQLClient.
*/
const fetchUser = useCallback(async () => {
const { user } = await client.getUser()
try {
console.log('🔍 Fetching user data from GraphQL...')
// Check if gqlClient exists and has the getUser method
if (!gqlClient || typeof gqlClient.getUser !== 'function') {
console.error('❌ GQL client not available or getUser method missing')
setAuthToken(null)
return
}
setAuthToken(user.gitHubToken)
}, [client])
const { user } = await gqlClient.getUser()
console.log('📊 User data received:', user)
if (user && user.gitHubToken) {
console.log('✅ GitHub token found in user data')
setAuthToken(user.gitHubToken)
} else {
console.log('❌ No GitHub token found in user data')
setAuthToken(null)
}
} catch (error) {
console.error('❌ Error fetching user:', error)
setAuthToken(null)
}
}, [gqlClient])
/**
* @function updateAuth
@ -82,25 +102,29 @@ export const OctokitProvider = ({
const octokit = useMemo(() => {
if (!authToken) {
setIsAuth(false)
console.log('🐙 Creating Octokit without auth token')
return new Octokit()
}
console.log('🐙 Creating Octokit with auth token')
setIsAuth(true)
return new Octokit({ auth: authToken })
}, [authToken])
// Only fetch user when GQL client is available
useEffect(() => {
fetchUser()
}, [fetchUser])
if (gqlClient) {
console.log('🔄 GQL client available, fetching user...')
fetchUser()
} else {
console.log('⏳ Waiting for GQL client...')
}
}, [gqlClient, fetchUser])
const debouncedUnauthorizedGithubHandler = useDebounceCallback(
useCallback(
(error: RequestError) => {
toast.error(`GitHub authentication error: ${error.message}`, {
// id: 'unauthorized-github-token',
// variant: 'error',
// onDismiss: dismiss
})
toast.error(`GitHub authentication error: ${error.message}`)
router.push(`/${orgSlug}/projects/create`)
},
@ -116,7 +140,9 @@ export const OctokitProvider = ({
error instanceof RequestError &&
error.status === UNAUTHORIZED_ERROR_CODE
) {
await client.unauthenticateGithub()
if (gqlClient && typeof gqlClient.unauthenticateGithub === 'function') {
await gqlClient.unauthenticateGithub()
}
await fetchUser()
debouncedUnauthorizedGithubHandler(error)
@ -131,7 +157,7 @@ export const OctokitProvider = ({
// Remove the interceptor when the component unmounts
octokit.hook.remove('request', interceptor)
}
}, [octokit, client, debouncedUnauthorizedGithubHandler, fetchUser])
}, [octokit, gqlClient, debouncedUnauthorizedGithubHandler, fetchUser])
return (
<OctokitContext.Provider value={{ octokit, updateAuth, isAuth }}>
@ -153,4 +179,4 @@ export const useOctokit = () => {
}
return context
}
}

View File

@ -1,183 +1,46 @@
import type React from 'react'
import {
type ReactNode,
createContext,
useContext,
useEffect,
useState
} from 'react'
import { toast } from 'sonner'
// src/context/WalletContext.tsx
'use client'
import { createContext, useContext } from 'react'
/**
* @interface WalletContextType
* @description Defines the structure of the WalletContext value.
* @property {object | null} wallet - The wallet object containing id and address.
* @property {boolean} isConnected - Indicates if the wallet is connected.
* @property {function} connect - Function to connect the wallet.
* @property {function} disconnect - Function to disconnect the wallet.
* Wallet Context Interface
*/
interface WalletContextType {
export interface WalletContextType {
// Wallet state
wallet: {
id: string
address?: string
address: string
} | null
isConnected: boolean
// Connection states
isConnected: boolean // SIWE authenticated + backend session
hasWalletAddress: boolean // Just has wallet address
isLoading: boolean // Connection/auth in progress
// Actions
connect: () => Promise<void>
disconnect: () => void
checkSession: () => Promise<boolean>
// Debug info
lastError?: string
}
/**
* @const WalletContext
* @description Creates a context for managing wallet connection state.
* Wallet Context
*/
const WalletContext = createContext<WalletContextType | undefined>(undefined)
export const WalletContext = createContext<WalletContextType | undefined>(undefined)
/**
* @component WalletProvider
* @description Provides the WalletContext to its children.
* @param {Object} props - Component props
* @param {ReactNode} props.children - The children to render.
* useWallet Hook
* @description Hook to access wallet context. Must be used within WalletProvider.
* @throws Error if used outside WalletProvider
*/
export const WalletProvider: React.FC<{ children: ReactNode }> = ({
children
}) => {
const [wallet, setWallet] = useState<WalletContextType['wallet']>(null)
const [isConnected, setIsConnected] = useState(false)
// useEffect(() => {
// const handleWalletMessage = (event: MessageEvent) => {
// if (event.origin !== process.env.NEXT_PUBLIC_WALLET_IFRAME_URL) return
// if (event.data.type === 'WALLET_ACCOUNTS_DATA') {
// const address = event.data.data[0].address
// setWallet({
// id: address,
// address: address
// })
// setIsConnected(true)
// toast.success('Wallet Connected', {
// // variant: 'success',
// duration: 3000
// // id: '',
// })
// }
// }
// window.addEventListener('message', handleWalletMessage)
// return () => window.removeEventListener('message', handleWalletMessage)
// }, [])
// const connect = async () => {
// const iframe = document.getElementById('checkBalanceIframe') as HTMLIFrameElement
// // const iframe = document.getElementById('walletIframe') as HTMLIFrameElement
// if (iframe?.contentWindow) {
// iframe.contentWindow.postMessage(
// {
// type: 'REQUEST_WALLET_ACCOUNTS',
// chainId: process.env.NEXT_PUBLIC_LACONICD_CHAIN_ID
// },
// process.env.NEXT_PUBLIC_WALLET_IFRAME_URL ?? ''
// )
// } else {
// toast.error('Wallet Connection Failed', {
// // description: 'Wallet iframe not found or not loaded',
// // variant: 'error',
// duration: 3000
// })
// }
// }
const connect = async () => {
console.log("🔌 Attempting to connect wallet...")
console.log("🔧 Environment variables:", {
NEXT_PUBLIC_WALLET_IFRAME_URL: process.env.NEXT_PUBLIC_WALLET_IFRAME_URL,
NEXT_PUBLIC_LACONICD_CHAIN_ID: process.env.NEXT_PUBLIC_LACONICD_CHAIN_ID
})
const iframe = document.getElementById('checkBalanceIframe') as HTMLIFrameElement
console.log("📱 Iframe element:", iframe)
if (iframe?.contentWindow) {
console.log("💬 Sending message to iframe:", {
type: 'REQUEST_WALLET_ACCOUNTS',
chainId: process.env.NEXT_PUBLIC_LACONICD_CHAIN_ID
})
iframe.contentWindow.postMessage(
{
type: 'REQUEST_WALLET_ACCOUNTS',
chainId: process.env.NEXT_PUBLIC_LACONICD_CHAIN_ID
},
process.env.NEXT_PUBLIC_WALLET_IFRAME_URL ?? ''
)
} else {
console.error("❌ Iframe not found or not loaded:", {
iframe: iframe,
contentWindow: iframe?.contentWindow
})
toast.error('Wallet Connection Failed', {
duration: 3000
})
}
}
// Add logging to the message handler
useEffect(() => {
const handleWalletMessage = (event: MessageEvent) => {
console.log("📨 Received message from wallet:", event.data)
if (event.origin !== process.env.NEXT_PUBLIC_WALLET_IFRAME_URL) {
console.warn("⚠️ Message from unexpected origin:", event.origin)
return
}
if (event.data.type === 'WALLET_ACCOUNTS_DATA') {
console.log("✅ Wallet accounts data received:", event.data)
const address = event.data.data[0].address
setWallet({
id: address,
address: address
})
setIsConnected(true)
toast.success('Wallet Connected', {
duration: 3000
})
} else {
console.log("📤 Received message type:", event.data.type, event.data)
}
}
window.addEventListener('message', handleWalletMessage)
return () => window.removeEventListener('message', handleWalletMessage)
}, [])
const disconnect = () => {
setWallet(null)
setIsConnected(false)
toast.info('Wallet Disconnected', {
duration: 3000
})
}
return (
<WalletContext.Provider
value={{ wallet, isConnected, connect, disconnect }}
>
{children}
</WalletContext.Provider>
)
}
/**
* @function useWallet
* @description A hook that provides access to the WalletContext.
* @returns {WalletContextType} The wallet context value.
* @throws {Error} If used outside of a WalletProvider.
*/
export const useWallet = () => {
export const useWallet = (): WalletContextType => {
const context = useContext(WalletContext)
if (context === undefined) {
throw new Error('useWallet must be used within a WalletProvider')
}
return context
}
}

View File

@ -1,246 +1,255 @@
import { AutoSignInIFrameModal } from '@/components/iframe/auto-sign-in'
import axios from 'axios'
import { usePathname, useRouter } from 'next/navigation'
// src/context/WalletProvider.tsx
'use client'
import type React from 'react'
import {
type ReactNode,
createContext,
useContext,
useEffect,
useState
} from 'react'
import { SiweMessage, generateNonce } from 'siwe'
import { type ReactNode, useState, useEffect, useCallback } from 'react'
import { toast } from 'sonner'
import { WalletContext, type WalletContextType } from './WalletContext'
import { AutoSignInIFrameModal } from '@/components/iframe/auto-sign-in'
const axiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
withCredentials: true
})
// Environment variables
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
const WALLET_IFRAME_URL = process.env.NEXT_PUBLIC_WALLET_IFRAME_URL || 'http://localhost:4000'
const WALLET_IFRAME_ID = 'wallet-communication-iframe'
/**
* @interface WalletContextType
* @description Defines the structure of the WalletContext value.
* @property {object | null} wallet - The wallet object containing id and address.
* @property {boolean} isConnected - Indicates if the wallet is connected.
* @property {boolean} isReady - Indicates if the app is ready to make API calls.
* @property {function} connect - Function to connect the wallet.
* @property {function} disconnect - Function to disconnect the wallet.
*/
interface WalletContextType {
wallet: {
id: string
address?: string
} | null
isConnected: boolean
isReady: boolean
connect: () => Promise<void>
disconnect: () => void
}
/**
* @const WalletContext
* @description Creates a context for managing wallet connection state.
*/
const WalletContext = createContext<WalletContextType | undefined>(undefined)
/**
* @component WalletContextProvider
* @description Provides the WalletContext to its children.
* @param {Object} props - Component props
* @param {ReactNode} props.children - The children to render.
*/
export const WalletContextProvider: React.FC<{ children: ReactNode }> = ({
children
}) => {
export const WalletContextProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
// Core wallet state
const [wallet, setWallet] = useState<WalletContextType['wallet']>(null)
const [isConnected, setIsConnected] = useState(false)
const [isReady, setIsReady] = useState(false)
const [accountAddress, setAccountAddress] = useState<string>()
const router = useRouter()
const pathname = usePathname()
const baseUrl = process.env.NEXT_PUBLIC_API_URL
const [hasWalletAddress, setHasWalletAddress] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [lastError, setLastError] = useState<string>()
// Modal state for SIWE authentication
const [showAuthModal, setShowAuthModal] = useState(false)
// Update isReady state when connection changes
useEffect(() => {
if (isConnected) {
// Add a small delay to ensure session is fully established
const timer = setTimeout(() => {
setIsReady(true)
console.log('Wallet is now ready for API calls')
}, 500)
return () => clearTimeout(timer)
}
setIsReady(false)
}, [isConnected])
// Check session status on mount
useEffect(() => {
fetch(`${baseUrl}/auth/session`, {
credentials: 'include'
}).then((res) => {
const path = pathname
console.log(res)
if (res.status !== 200) {
setIsConnected(false)
localStorage.clear()
if (path !== '/login') {
router.push('/login')
}
// Check if we have an active backend session
const checkSession = useCallback(async (): Promise<boolean> => {
try {
const response = await fetch(`${BACKEND_URL}/auth/session`, {
method: 'GET',
credentials: 'include',
})
const sessionExists = response.ok
setIsConnected(sessionExists)
if (sessionExists) {
console.log('✅ Active wallet session found')
} else {
setIsConnected(true)
if (path === '/login') {
router.push('/')
}
console.log('❌ No active wallet session')
}
})
}, [pathname, router, baseUrl])
return sessionExists
} catch (error) {
console.error('Error checking wallet session:', error)
setLastError('Failed to check session')
setIsConnected(false)
return false
}
}, [])
// Handle wallet messages for account data
// Initialize session check on mount
useEffect(() => {
checkSession()
}, [checkSession])
// Handle wallet messages from iframe
useEffect(() => {
const handleWalletMessage = (event: MessageEvent) => {
if (event.origin !== process.env.NEXT_PUBLIC_WALLET_IFRAME_URL) return
console.log(event)
// Security check
if (event.origin !== WALLET_IFRAME_URL) {
console.warn('⚠️ Message from unexpected origin:', event.origin)
return
}
console.log('📨 Wallet message received:', {
type: event.data.type,
origin: event.origin,
data: event.data.data
})
if (event.data.type === 'WALLET_ACCOUNTS_DATA') {
const address = event.data.data[0].address
handleWalletAccountsData(event.data.data)
}
}
const handleWalletAccountsData = (data: any) => {
let address: string | undefined
// Handle different data formats from wallet
if (Array.isArray(data)) {
if (typeof data[0] === 'string') {
address = data[0]
} else if (data[0]?.address) {
address = data[0].address
}
}
if (address) {
console.log('✅ Wallet address received:', address)
setWallet({
id: address,
address: address
})
setAccountAddress(address)
setIsConnected(true)
toast.success('Wallet Connected', {
// variant: 'success',
duration: 3000
// id: '',
setHasWalletAddress(true)
setLastError(undefined)
// Check if we already have a session for this wallet
checkSession().then(hasSession => {
if (!hasSession) {
// Need SIWE authentication
console.log('🔐 Starting SIWE authentication...')
setShowAuthModal(true)
} else {
toast.success('Wallet connected!')
}
})
} else {
console.error('❌ Could not extract address from wallet data:', data)
setLastError('Invalid wallet data received')
toast.error('Invalid wallet data received')
}
}
window.addEventListener('message', handleWalletMessage)
return () => window.removeEventListener('message', handleWalletMessage)
}, [])
}, [checkSession])
// Handle sign-in response from the wallet iframe
useEffect(() => {
const handleSignInResponse = async (event: MessageEvent) => {
if (event.origin !== process.env.NEXT_PUBLIC_WALLET_IFRAME_URL) return
if (event.data.type === 'SIGN_IN_RESPONSE') {
try {
const { success } = (
await axiosInstance.post('/auth/validate', {
message: event.data.data.message,
signature: event.data.data.signature
})
).data
if (success === true) {
setIsConnected(true)
if (pathname === '/login') {
router.push('/')
}
}
} catch (error) {
console.error('Error signing in:', error)
}
}
// Connect to wallet
const connect = useCallback(async () => {
if (isLoading) {
console.log('⏸️ Connection already in progress')
return
}
window.addEventListener('message', handleSignInResponse)
return () => {
window.removeEventListener('message', handleSignInResponse)
}
}, [router, pathname])
// Initiate auto sign-in when account address is available
useEffect(() => {
const initiateAutoSignIn = async () => {
if (!accountAddress) return
const iframe = document.getElementById(
'walletAuthFrame'
) as HTMLIFrameElement
if (!iframe?.contentWindow) {
console.error('Iframe not found or not loaded')
return
setIsLoading(true)
setLastError(undefined)
try {
console.log('🔌 Attempting to connect wallet...')
// Find the wallet communication iframe
const iframe = document.getElementById(WALLET_IFRAME_ID) as HTMLIFrameElement
if (!iframe) {
throw new Error('Wallet communication interface not found')
}
const message = new SiweMessage({
version: '1',
domain: window.location.host,
uri: window.location.origin,
chainId: 1,
address: accountAddress,
nonce: generateNonce(),
statement: 'Sign in With Ethereum.'
}).prepareMessage()
if (!iframe.contentWindow) {
throw new Error('Wallet interface not loaded')
}
iframe.contentWindow.postMessage(
{
type: 'AUTO_SIGN_IN',
chainId: '1',
message
},
process.env.NEXT_PUBLIC_WALLET_IFRAME_URL ?? ''
)
}
initiateAutoSignIn()
}, [accountAddress])
const connect = async () => {
const iframe = document.getElementById('walletIframe') as HTMLIFrameElement
if (iframe?.contentWindow) {
console.log('📤 Sending wallet connection request...')
iframe.contentWindow.postMessage(
{
type: 'REQUEST_WALLET_ACCOUNTS',
chainId: process.env.NEXT_PUBLIC_LACONICD_CHAIN_ID
chainId: process.env.NEXT_PUBLIC_LACONICD_CHAIN_ID || '1'
},
process.env.NEXT_PUBLIC_WALLET_IFRAME_URL ?? ''
WALLET_IFRAME_URL
)
} else {
toast.error('Wallet Connection Failed', {
// description: 'Wallet iframe not found or not loaded',
// variant: 'error',
duration: 3000
})
}
}
const disconnect = () => {
// Set a timeout for connection attempt
const timeoutId = setTimeout(() => {
if (!hasWalletAddress) {
setLastError('Connection timeout')
toast.error('Wallet connection timeout. Please try again.')
setIsLoading(false)
}
}, 15000) // 15 second timeout
// Clear timeout if we get an address
if (hasWalletAddress) {
clearTimeout(timeoutId)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to connect wallet'
console.error('❌ Error connecting wallet:', error)
setLastError(errorMessage)
toast.error(errorMessage)
setIsLoading(false)
}
}, [isLoading, hasWalletAddress])
// Update loading state when address is received
useEffect(() => {
if (hasWalletAddress && isLoading) {
setIsLoading(false)
}
}, [hasWalletAddress, isLoading])
// Handle successful SIWE authentication
const handleAuthComplete = useCallback((success: boolean) => {
if (success) {
setIsConnected(true)
toast.success('Wallet authentication complete!')
console.log('✅ SIWE authentication successful')
} else {
console.log('❌ SIWE authentication failed')
setLastError('SIWE authentication failed')
toast.error('Wallet authentication failed')
}
setShowAuthModal(false)
}, [])
// Disconnect wallet
const disconnect = useCallback(() => {
setWallet(null)
setIsConnected(false)
toast.info('Wallet Disconnected', {
duration: 3000
})
setHasWalletAddress(false)
setShowAuthModal(false)
setLastError(undefined)
// Call backend logout
fetch(`${BACKEND_URL}/auth/logout`, {
method: 'POST',
credentials: 'include'
}).catch(console.error)
toast.info('Wallet disconnected')
}, [])
const contextValue: WalletContextType = {
wallet,
isConnected,
hasWalletAddress,
isLoading,
connect,
disconnect,
checkSession,
lastError
}
return (
<WalletContext.Provider
value={{ wallet, isConnected, isReady, connect, disconnect }}
>
<WalletContext.Provider value={contextValue}>
{children}
{!isConnected && <AutoSignInIFrameModal />}
{/* Always-present hidden iframe for wallet communication */}
<iframe
id={WALLET_IFRAME_ID}
src={WALLET_IFRAME_URL}
style={{
position: 'absolute',
left: '-9999px',
width: '1px',
height: '1px',
opacity: 0,
pointerEvents: 'none'
}}
sandbox="allow-scripts allow-same-origin"
title="Wallet Communication Interface"
/>
{/* SIWE Authentication Modal */}
{showAuthModal && (
<AutoSignInIFrameModal
onAuthComplete={handleAuthComplete}
onClose={() => setShowAuthModal(false)}
/>
)}
</WalletContext.Provider>
)
}
/**
* @function useWallet
* @description A hook that provides access to the WalletContext.
* @returns {WalletContextType} The wallet context value.
* @throws {Error} If used outside of a WalletContextProvider.
*/
export const useWallet = () => {
const context = useContext(WalletContext)
if (context === undefined) {
throw new Error('useWallet must be used within a WalletContextProvider')
}
return context
}
}

View File

@ -1,169 +0,0 @@
// "use client";
// import { useState, useEffect } from "react";
// import { Octokit } from "@octokit/rest";
// export function useRepoData(repoId: string) {
// const [repoData, setRepoData] = useState<any>(null);
// const [isLoading, setIsLoading] = useState(true);
// const [error, setError] = useState<string | null>(null);
// useEffect(() => {
// async function fetchRepoData() {
// setIsLoading(true);
// try {
// // Use the same hardcoded token as in projects/page.tsx
// const authToken = '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';
// // Create Octokit instance with token
// const octokit = new Octokit({
// auth: authToken
// });
// // Fetch repos from GitHub
// const { data: repos } = await octokit.repos.listForAuthenticatedUser();
// // Find the specific repo by ID
// const repo = repos.find(repo => repo.id.toString() === repoId);
// if (!repo) {
// setError("Repository not found");
// setRepoData(null);
// } else {
// setRepoData(repo);
// setError(null);
// }
// } catch (err) {
// console.error('Error fetching GitHub repo:', err);
// setError('Failed to fetch repository data');
// setRepoData(null);
// } finally {
// setIsLoading(false);
// }
// }
// fetchRepoData();
// }, [repoId]);
// return { repoData, isLoading, error };
// }
"use client";
import { useState, useEffect } from "react";
import { useAuth, useUser } from "@clerk/nextjs";
import { Octokit } from "@octokit/rest";
// Define the return type of the hook
interface UseRepoDataReturn {
repoData: any;
isLoading: boolean;
error: string | null;
}
/**
* A hook to fetch repository data from GitHub
*
* @param repoId - The GitHub repository ID to fetch, or empty string to fetch all repos
* @returns Object containing repository data, loading state, and any errors
*/
export function useRepoData(repoId: string): UseRepoDataReturn {
const [repoData, setRepoData] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Get auth data from Clerk
const { isLoaded: isAuthLoaded, userId } = useAuth();
const { isLoaded: isUserLoaded, user } = useUser();
useEffect(() => {
let isMounted = true;
async function fetchRepoData() {
try {
if (!userId || !user) {
if (isMounted) {
setError("User not authenticated");
setIsLoading(false);
}
return;
}
// Use the fallback token for now
// In production, this would be replaced with a more robust solution
const authToken = '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';
// Check for a different way to get GitHub authorization
let githubToken = authToken;
// Try to get from session storage if available (client-side only)
if (typeof window !== 'undefined') {
const storedToken = sessionStorage.getItem('github_token');
if (storedToken) {
console.log("Using token from session storage");
githubToken = storedToken;
}
}
// Create Octokit instance with token
const octokit = new Octokit({
auth: githubToken
});
// Fetch repos from GitHub
const { data: repos } = await octokit.repos.listForAuthenticatedUser();
// If no repoId is provided, return all repos
if (!repoId) {
if (isMounted) {
setRepoData(repos);
setError(null);
setIsLoading(false);
}
return;
}
// Find the specific repo by ID if repoId is provided
const repo = repos.find(repo => repo.id.toString() === repoId);
if (!repo) {
if (isMounted) {
setError("Repository not found");
setRepoData(null);
setIsLoading(false);
}
} else {
if (isMounted) {
setRepoData(repo);
setError(null);
setIsLoading(false);
}
}
} catch (err) {
console.error('Error fetching GitHub repo:', err);
if (isMounted) {
setError('Failed to fetch repository data');
setRepoData(null);
setIsLoading(false);
}
}
}
// Only fetch if the user is authenticated and Clerk is loaded
if (isAuthLoaded && isUserLoaded && userId) {
fetchRepoData();
} else if (isAuthLoaded && isUserLoaded && !userId) {
if (isMounted) {
setError("User not authenticated");
setIsLoading(false);
}
}
return () => {
isMounted = false;
};
}, [repoId, isAuthLoaded, isUserLoaded, userId, user]);
return { repoData, isLoading, error };
}

View File

@ -0,0 +1,215 @@
// src/hooks/useAuthStatus.tsx
'use client'
import { useAuth, useUser } from '@clerk/nextjs'
import { useWallet } from '@/context/WalletContext' // Use the full provider!
import { useBackend } from '@/context/BackendContext'
import { useGQLClient } from '@/context'
import { useState, useEffect, useCallback } from 'react'
/**
* @interface AuthStatus
* @description Comprehensive authentication status across all systems
*/
export interface AuthStatus {
// Individual auth systems
clerk: {
isSignedIn: boolean
isLoaded: boolean
hasGithubConnected: boolean
user: any
}
wallet: {
isConnected: boolean // SIWE authenticated + backend session
hasAddress: boolean // Just has wallet address
wallet: any
}
backend: {
isConnected: boolean
hasGithubAuth: boolean
isLoading: boolean
}
// Computed status
isFullyAuthenticated: boolean
isReady: boolean
// What's missing (for UI feedback)
missing: {
clerkSignIn: boolean
clerkGithub: boolean
walletConnection: boolean
backendConnection: boolean
githubBackendSync: boolean
}
// Progress (for UI indicators)
progress: {
completed: number
total: number
percentage: number
}
}
/**
* @interface AuthActions
* @description Available authentication actions
*/
export interface AuthActions {
// Wallet actions
connectWallet: () => Promise<void>
// Combined actions
refreshAllStatus: () => Promise<void>
checkGithubBackendAuth: () => Promise<boolean>
}
/**
* @hook useAuthStatus
* @description Provides comprehensive authentication status and actions
* @returns Combined auth status and actions
*/
export function useAuthStatus(): AuthStatus & AuthActions {
// Clerk authentication
const { isSignedIn, isLoaded: isClerkLoaded } = useAuth()
const { user, isLoaded: isUserLoaded } = useUser()
// Wallet authentication
const {
isConnected: isWalletSessionActive, // SIWE authenticated
hasWalletAddress,
wallet,
connect: connectWallet
} = useWallet()
// Backend authentication
const {
isBackendConnected,
isLoading: isBackendLoading,
refreshStatus: refreshBackendStatus
} = useBackend()
// GraphQL client for checking GitHub backend auth
const gqlClient = useGQLClient()
// GitHub backend auth state
const [isGithubBackendAuth, setIsGithubBackendAuth] = useState(false)
const [isCheckingGithubAuth, setIsCheckingGithubAuth] = useState(false)
// Check GitHub backend auth via GraphQL
const checkGithubBackendAuth = useCallback(async (): Promise<boolean> => {
if (!isBackendConnected) return false
try {
setIsCheckingGithubAuth(true)
const userData = await gqlClient.getUser()
const hasGitHubToken = !!userData.user.gitHubToken
setIsGithubBackendAuth(hasGitHubToken)
return hasGitHubToken
} catch (error) {
console.error('Error checking GitHub backend auth:', error)
setIsGithubBackendAuth(false)
return false
} finally {
setIsCheckingGithubAuth(false)
}
}, [isBackendConnected, gqlClient])
// Check GitHub auth when backend connection changes
useEffect(() => {
if (isBackendConnected) {
checkGithubBackendAuth()
} else {
setIsGithubBackendAuth(false)
}
}, [isBackendConnected, checkGithubBackendAuth])
// Check backend connection when wallet session is active (SIWE completed)
useEffect(() => {
if (isWalletSessionActive) {
// Wait a moment for wallet session to be established, then check backend
const timer = setTimeout(() => {
refreshBackendStatus()
}, 1000)
return () => clearTimeout(timer)
}
}, [isWalletSessionActive, refreshBackendStatus])
// Check if GitHub is connected in Clerk
const hasGithubInClerk = user?.externalAccounts?.find(
account => account.provider === 'github' || account.verification?.strategy === 'oauth_github'
) !== undefined
// Calculate what's missing
const missing = {
clerkSignIn: !isSignedIn,
clerkGithub: isSignedIn && !hasGithubInClerk,
walletConnection: !hasWalletAddress, // Just need wallet address for this step
backendConnection: hasWalletAddress && !isWalletSessionActive, // Need SIWE auth for backend
githubBackendSync: isBackendConnected && !isGithubBackendAuth
}
// Calculate progress
const authSteps = [
isSignedIn, // Clerk sign in
hasGithubInClerk, // GitHub connected to Clerk
hasWalletAddress, // Wallet address obtained
isWalletSessionActive, // SIWE authentication completed
isGithubBackendAuth // GitHub synced to backend
]
const completedSteps = authSteps.filter(Boolean).length
const totalSteps = authSteps.length
const progressPercentage = Math.round((completedSteps / totalSteps) * 100)
// Determine if fully authenticated
const isFullyAuthenticated = authSteps.every(Boolean)
// Determine if ready (all auth systems loaded)
const isReady = isClerkLoaded && isUserLoaded && !isBackendLoading && !isCheckingGithubAuth
// Combined refresh action
const refreshAllStatus = async () => {
await refreshBackendStatus()
await checkGithubBackendAuth()
}
return {
// Individual systems
clerk: {
isSignedIn,
isLoaded: isClerkLoaded && isUserLoaded,
hasGithubConnected: hasGithubInClerk,
user
},
wallet: {
isConnected: isWalletSessionActive,
hasAddress: hasWalletAddress,
wallet
},
backend: {
isConnected: isBackendConnected,
hasGithubAuth: isGithubBackendAuth,
isLoading: isBackendLoading || isCheckingGithubAuth
},
// Computed status
isFullyAuthenticated,
isReady,
// Missing items
missing,
// Progress
progress: {
completed: completedSteps,
total: totalSteps,
percentage: progressPercentage
},
// Actions
connectWallet,
refreshAllStatus,
checkGithubBackendAuth
}
}

View File

@ -1,94 +1,131 @@
// src/hooks/useDeployment.tsx
'use client'
import { useState } from 'react'
import { useGQLClient } from '@/context'
import { toast } from 'sonner'
interface DeploymentConfig {
repositoryUrl: string;
branch: string;
environmentVariables?: Record<string, string>;
projectName?: string;
customDomain?: string;
// Define the structure of deployment configuration
export interface DeploymentConfig {
projectId?: string
organizationSlug: string
repository: string
branch: string
name: string
environmentVariables?: Array<{
key: string
value: string
environments: string[]
}>
}
interface DeploymentResult {
id: string;
url: string;
status: 'pending' | 'building' | 'ready' | 'error';
// Define the structure of deployment result
export interface DeploymentResult {
id: string
url?: string
status: string
}
export function useDeployment() {
const [isDeploying, setIsDeploying] = useState(false)
const [deploymentResult, setDeploymentResult] = useState<DeploymentResult | null>(null)
const [error, setError] = useState<string | null>(null)
const gqlClient = useGQLClient()
// Function to create a new project and deploy it
const deployRepository = async (config: DeploymentConfig): Promise<DeploymentResult> => {
setIsDeploying(true)
setError(null)
try {
// This is a placeholder query - you'll need to replace it with the actual GraphQL mutation
// based on the snowballtools-base API schema
const result = await gqlClient.mutate({
mutation: `
mutation CreateDeployment($input: CreateDeploymentInput!) {
createDeployment(input: $input) {
id
url
status
}
}
`,
variables: {
input: {
repositoryUrl: config.repositoryUrl,
branch: config.branch,
environmentVariables: config.environmentVariables || {},
projectName: config.projectName,
customDomain: config.customDomain
}
}
})
console.log('🚀 Starting repository deployment:', config)
// Use the addProject mutation from your existing GraphQL client
const projectResult = await gqlClient.addProject(
config.organizationSlug,
{
name: config.name,
repository: config.repository,
prodBranch: config.branch,
template: 'webapp', // Default template
paymentAddress: "0x1ac42F4A25Ae0137d10a825a2e33e32de0F6B57E", // Should come from wallet
txHash: "0x0000000000000000000000000000000000000000000000000000000000000000" // Placeholder
},
undefined, // lrn - will be handled in configure step
undefined, // auctionParams - will be handled in configure step
config.environmentVariables || []
)
if (!projectResult.addProject?.id) {
throw new Error('Failed to create project')
}
toast.success('Project created successfully!')
// Wait a moment for deployment to start
await new Promise(resolve => setTimeout(resolve, 2000))
const deployment = result.data.createDeployment
setDeploymentResult(deployment)
// Get the newly created project to find its deployment
const projectData = await gqlClient.getProject(projectResult.addProject.id)
// Find the most recent deployment
const deployment = projectData.project?.deployments?.[0]
const deploymentResult: DeploymentResult = {
id: deployment?.id || projectResult.addProject.id,
url: deployment?.applicationDeploymentRecordData?.url,
status: deployment?.status || 'Building'
}
setDeploymentResult(deploymentResult)
toast.success('Deployment initiated successfully')
return deployment
return deploymentResult
} catch (error) {
console.error('Deployment failed:', error)
toast.error('Failed to deploy repository')
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
setError(errorMessage)
toast.error(`Failed to deploy repository: ${errorMessage}`)
throw error
} finally {
setIsDeploying(false)
}
}
const getDeploymentStatus = async (deploymentId: string): Promise<string> => {
// Function to check the status of a deployment
const getDeploymentStatus = async (projectId: string) => {
try {
const result = await gqlClient.query({
query: `
query GetDeploymentStatus($id: ID!) {
deployment(id: $id) {
id
status
}
}
`,
variables: {
id: deploymentId
}
})
return result.data.deployment.status
const result = await gqlClient.getProject(projectId)
return result.project?.deployments?.[0]?.status
} catch (error) {
console.error('Failed to get deployment status:', error)
throw error
}
}
// Function to get all deployments for a project
const getDeployments = async (projectId: string) => {
try {
return await gqlClient.getDeployments(projectId)
} catch (error) {
console.error('Failed to get deployments:', error)
throw error
}
}
const reset = () => {
setDeploymentResult(null)
setError(null)
setIsDeploying(false)
}
return {
deployRepository,
getDeploymentStatus,
getDeployments,
isDeploying,
deploymentResult
deploymentResult,
error,
reset
}
}

View File

@ -62,13 +62,6 @@ export function useRepoData(repoId: string): UseRepoDataReturn {
}
}
// Final fallback to hardcoded token - ONLY for development
if (!token && process.env.NODE_ENV === 'development') {
// Store your token in .env.local instead, using this only as last resort
token = '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';
console.warn('Using hardcoded token - INSECURE. Use environment variables instead.');
}
// Create Octokit instance with whatever token we found
if (token) {
setOctokit(new Octokit({ auth: token }));

View File

@ -0,0 +1,186 @@
// src/hooks/useTemplateDeployment.tsx
'use client'
import { useState } from 'react'
import { useGQLClient } from '@/context'
import { useWallet } from '@/context/WalletContext'
import { useUser } from '@clerk/nextjs'
import { toast } from 'sonner'
import type { TemplateDetail } from '@/constants/templates'
export interface TemplateDeploymentConfig {
template: TemplateDetail
projectName: string
organizationSlug: string
environmentVariables?: Array<{
key: string
value: string
environments: string[]
}>
deployerLrn?: string
}
export interface TemplateDeploymentResult {
projectId: string
repositoryUrl: string
deploymentUrl?: string
deploymentId?: string
}
export function useTemplateDeployment() {
const [isDeploying, setIsDeploying] = useState(false)
const [deploymentResult, setDeploymentResult] = useState<TemplateDeploymentResult | null>(null)
const [error, setError] = useState<string | null>(null)
const gqlClient = useGQLClient()
const { wallet } = useWallet()
const { user } = useUser()
const deployTemplate = async (config: TemplateDeploymentConfig): Promise<TemplateDeploymentResult> => {
setIsDeploying(true)
setError(null)
setDeploymentResult(null)
try {
console.log('🚀 Starting template deployment:', config)
// Validate required data
if (!wallet?.address) {
throw new Error('Wallet not connected')
}
if (!user) {
throw new Error('User not authenticated')
}
// Get GitHub username from Clerk external accounts
const githubAccount = user.externalAccounts?.find(account => account.provider === 'github')
const githubUsername = githubAccount?.username
if (!githubUsername) {
throw new Error('GitHub account not connected')
}
console.log('🔍 GitHub user info:', {
githubUsername,
githubAccount: githubAccount?.username,
userExternalAccounts: user.externalAccounts?.length
})
// Parse template repository (format: "owner/repo")
const [templateOwner, templateRepo] = config.template.repoFullName.split('/')
if (!templateOwner || !templateRepo) {
throw new Error('Invalid template repository format')
}
// Debug logging
console.log('🔍 Template parsing details:', {
originalTemplate: config.template.repoFullName,
parsedOwner: templateOwner,
parsedRepo: templateRepo,
templateId: config.template.id,
templateName: config.template.name
})
const requestData = {
templateOwner,
templateRepo,
owner: githubUsername, // Use the authenticated GitHub username
name: config.projectName,
isPrivate: false,
paymentAddress: wallet.address,
txHash: "0x0000000000000000000000000000000000000000000000000000000000000000" // Placeholder - will be updated if payment is required
}
console.log('🔍 Request data being sent to backend:', requestData)
console.log('🔍 Organization slug:', config.organizationSlug)
console.log('🔍 Deployer LRN:', config.deployerLrn)
console.log('🔍 Environment variables:', config.environmentVariables)
toast.info('Creating repository from template...')
// Use the backend's addProjectFromTemplate method
const projectResult = await gqlClient.addProjectFromTemplate(
config.organizationSlug,
requestData,
config.deployerLrn, // deployer LRN for direct deployment
undefined, // auctionParams - not used for LRN deployments
config.environmentVariables || []
)
console.log('🔍 Backend response:', projectResult)
if (!projectResult.addProjectFromTemplate?.id) {
throw new Error('Failed to create project from template')
}
toast.success('Repository created from template!')
// Wait for deployment to start
await new Promise(resolve => setTimeout(resolve, 3000))
// Get project with deployment info
const projectData = await gqlClient.getProject(projectResult.addProjectFromTemplate.id)
console.log('🔍 Project data after creation:', projectData)
if (!projectData.project) {
throw new Error('Project not found after creation')
}
const deployment = projectData.project.deployments?.[0]
const result: TemplateDeploymentResult = {
projectId: projectResult.addProjectFromTemplate.id,
repositoryUrl: `https://github.com/${projectData.project.repository}`,
deploymentUrl: deployment?.applicationDeploymentRecordData?.url,
deploymentId: deployment?.id
}
console.log('🔍 Final deployment result:', result)
setDeploymentResult(result)
toast.success('Template deployed successfully!')
return result
} catch (error) {
console.error('❌ Template deployment failed:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
setError(errorMessage)
toast.error(`Template deployment failed: ${errorMessage}`)
throw error
} finally {
setIsDeploying(false)
}
}
const getTemplateInfo = async (templateRepo: string) => {
try {
// This would fetch template information if needed
// For now, we can just return the repo name
return {
name: templateRepo,
description: `Template from ${templateRepo}`
}
} catch (error) {
console.error('Error fetching template info:', error)
throw error
}
}
const reset = () => {
setDeploymentResult(null)
setError(null)
setIsDeploying(false)
}
return {
deployTemplate,
getTemplateInfo,
isDeploying,
deploymentResult,
error,
reset
}
}

View File

@ -34,5 +34,9 @@
"packageManager": "pnpm@10.5.1",
"engines": {
"node": ">=22.14.0"
},
"dependencies": {
"ethers": "^6.14.1",
"siwe": "^3.0.0"
}
}

90
pnpm-lock.yaml generated
View File

@ -7,6 +7,13 @@ settings:
importers:
.:
dependencies:
ethers:
specifier: ^6.14.1
version: 6.14.1
siwe:
specifier: ^3.0.0
version: 3.0.0(ethers@6.14.1)
devDependencies:
'@biomejs/biome':
specifier: ^1.9.4
@ -112,7 +119,7 @@ importers:
version: 7.7.1
siwe:
specifier: ^3.0.0
version: 3.0.0(ethers@5.8.0)
version: 3.0.0(ethers@6.14.1)
toml:
specifier: ^3.0.0
version: 3.0.0
@ -320,7 +327,7 @@ importers:
version: 2.15.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
siwe:
specifier: ^3.0.0
version: 3.0.0(ethers@5.8.0)
version: 3.0.0(ethers@6.14.1)
sonner:
specifier: ^2.0.1
version: 2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -399,7 +406,7 @@ importers:
version: 1.9.4
'@biomejs/monorepo':
specifier: github:biomejs/biome
version: https://codeload.github.com/biomejs/biome/tar.gz/7e51cd1e3139c46b4b45eab71936d1641230a566
version: https://codeload.github.com/biomejs/biome/tar.gz/a82a1f2e50c5c9b22cc696960bc167631d1de455
'@hookform/resolvers':
specifier: ^4.1.2
version: 4.1.3(react-hook-form@7.54.2(react@19.0.0))
@ -575,6 +582,9 @@ importers:
packages:
'@adraffy/ens-normalize@1.10.1':
resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==}
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
@ -720,9 +730,10 @@ packages:
cpu: [x64]
os: [win32]
'@biomejs/monorepo@https://codeload.github.com/biomejs/biome/tar.gz/7e51cd1e3139c46b4b45eab71936d1641230a566':
resolution: {tarball: https://codeload.github.com/biomejs/biome/tar.gz/7e51cd1e3139c46b4b45eab71936d1641230a566}
'@biomejs/monorepo@https://codeload.github.com/biomejs/biome/tar.gz/a82a1f2e50c5c9b22cc696960bc167631d1de455':
resolution: {tarball: https://codeload.github.com/biomejs/biome/tar.gz/a82a1f2e50c5c9b22cc696960bc167631d1de455}
version: 0.0.0
engines: {pnpm: 10.8.1}
'@cerc-io/laconic-registry-cli@0.2.10':
resolution: {integrity: sha512-rwrZhFgYZiMh2k+9E/aiyRhFLApydRUwclATb0f6hsFAkxARuaibHsJNy7eF2N/AQ4d6HAvpkXACJoVrGOppmw==, tarball: https://git.vdb.to/api/packages/cerc-io/npm/%40cerc-io%2Flaconic-registry-cli/-/0.2.10/laconic-registry-cli-0.2.10.tgz}
@ -1392,10 +1403,17 @@ packages:
cpu: [x64]
os: [win32]
'@noble/curves@1.2.0':
resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==}
'@noble/curves@1.8.1':
resolution: {integrity: sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==}
engines: {node: ^14.21.3 || >=16}
'@noble/hashes@1.3.2':
resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==}
engines: {node: '>= 16'}
'@noble/hashes@1.7.1':
resolution: {integrity: sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==}
engines: {node: ^14.21.3 || >=16}
@ -2615,6 +2633,9 @@ packages:
'@types/node@22.13.9':
resolution: {integrity: sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==}
'@types/node@22.7.5':
resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==}
'@types/pbkdf2@3.1.2':
resolution: {integrity: sha512-uRwJqmiXmh9++aSu1VNEn3iIxWOhd8AHXNSdlaLfdAAdSTY9jYVeGWnzejM3dvrkbqE3/hyQkQQ29IFATEGlew==}
@ -2691,6 +2712,9 @@ packages:
aes-js@3.0.0:
resolution: {integrity: sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==}
aes-js@4.0.0-beta.5:
resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==}
agent-base@7.1.3:
resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==}
engines: {node: '>= 14'}
@ -3504,6 +3528,10 @@ packages:
ethers@5.8.0:
resolution: {integrity: sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg==}
ethers@6.14.1:
resolution: {integrity: sha512-JnFiPFi3sK2Z6y7jZ3qrafDMwiXmU+6cNZ0M+kPq+mTy9skqEzwqAdFW3nb/em2xjlIVXX6Lz8ID6i3LmS4+fQ==}
engines: {node: '>=14.0.0'}
ethjs-util@0.1.6:
resolution: {integrity: sha512-CUnVOQq7gSpDHZVVrQW8ExxUETWrnrvXYvYz55wOU8Uj4VCgw56XC2B/fVqQN+f7gmrnRHSLVnFAwsCuNwji8w==}
engines: {node: '>=6.5.0', npm: '>=3'}
@ -5259,6 +5287,9 @@ packages:
tslib@2.4.1:
resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==}
tslib@2.7.0:
resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@ -5592,6 +5623,18 @@ packages:
utf-8-validate:
optional: true
ws@8.17.1:
resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
ws@8.18.0:
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
engines: {node: '>=10.0.0'}
@ -5677,6 +5720,8 @@ packages:
snapshots:
'@adraffy/ens-normalize@1.10.1': {}
'@alloc/quick-lru@5.2.0': {}
'@apollo/client@3.13.3(@types/react@18.3.0)(graphql@16.10.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
@ -5826,7 +5871,7 @@ snapshots:
'@biomejs/cli-win32-x64@1.9.4':
optional: true
'@biomejs/monorepo@https://codeload.github.com/biomejs/biome/tar.gz/7e51cd1e3139c46b4b45eab71936d1641230a566': {}
'@biomejs/monorepo@https://codeload.github.com/biomejs/biome/tar.gz/a82a1f2e50c5c9b22cc696960bc167631d1de455': {}
'@cerc-io/laconic-registry-cli@0.2.10':
dependencies:
@ -6775,10 +6820,16 @@ snapshots:
'@next/swc-win32-x64-msvc@15.2.1':
optional: true
'@noble/curves@1.2.0':
dependencies:
'@noble/hashes': 1.3.2
'@noble/curves@1.8.1':
dependencies:
'@noble/hashes': 1.7.1
'@noble/hashes@1.3.2': {}
'@noble/hashes@1.7.1': {}
'@nodelib/fs.scandir@2.1.5':
@ -8112,6 +8163,10 @@ snapshots:
dependencies:
undici-types: 6.20.0
'@types/node@22.7.5':
dependencies:
undici-types: 6.19.8
'@types/pbkdf2@3.1.2':
dependencies:
'@types/node': 20.17.23
@ -8189,6 +8244,8 @@ snapshots:
aes-js@3.0.0: {}
aes-js@4.0.0-beta.5: {}
agent-base@7.1.3: {}
aggregate-error@3.1.0:
@ -9143,6 +9200,19 @@ snapshots:
- bufferutil
- utf-8-validate
ethers@6.14.1:
dependencies:
'@adraffy/ens-normalize': 1.10.1
'@noble/curves': 1.2.0
'@noble/hashes': 1.3.2
'@types/node': 22.7.5
aes-js: 4.0.0-beta.5
tslib: 2.7.0
ws: 8.17.1
transitivePeerDependencies:
- bufferutil
- utf-8-validate
ethjs-util@0.1.6:
dependencies:
is-hex-prefixed: 1.0.0
@ -10727,11 +10797,11 @@ snapshots:
is-arrayish: 0.3.2
optional: true
siwe@3.0.0(ethers@5.8.0):
siwe@3.0.0(ethers@6.14.1):
dependencies:
'@spruceid/siwe-parser': 3.0.0
'@stablelib/random': 1.0.2
ethers: 5.8.0
ethers: 6.14.1
slash@3.0.0: {}
@ -11057,6 +11127,8 @@ snapshots:
tslib@2.4.1: {}
tslib@2.7.0: {}
tslib@2.8.1: {}
tsscmp@1.0.6: {}
@ -11324,6 +11396,8 @@ snapshots:
ws@7.5.10: {}
ws@8.17.1: {}
ws@8.18.0: {}
xss@1.0.15: