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 { useParams } from 'next/navigation';
import { PageWrapper } from '@/components/foundation'; import { PageWrapper } from '@/components/foundation';
import { DeploymentDetailsCard } from '@/components/projects/project/deployments/DeploymentDetailsCard'; 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 { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import { IconButton } from '@workspace/ui/components/button'; import { Button } from '@workspace/ui/components/button';
import { Rocket } from 'lucide-react'; import { Square, Search, Calendar, ChevronDown } from 'lucide-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRepoData } from '@/hooks/useRepoData'; import { useRepoData } from '@/hooks/useRepoData';
import type { Deployment, Domain } from '@/types'; 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() { export default function DeploymentsPage() {
const router = useRouter(); const router = useRouter();
@ -27,6 +28,11 @@ export default function DeploymentsPage() {
const [filteredDeployments, setFilteredDeployments] = useState<Deployment[]>([]); const [filteredDeployments, setFilteredDeployments] = useState<Deployment[]>([]);
const [prodBranchDomains, setProdBranchDomains] = useState<Domain[]>([]); 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 // Create a default deployment
const defaultDeployment: Deployment = { const defaultDeployment: Deployment = {
id: 'default', id: 'default',
@ -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(() => { useEffect(() => {
const mockDeployments = [defaultDeployment, secondDeployment]; // For testing the empty state
setDeployments(mockDeployments); setDeployments([]);
setFilteredDeployments(mockDeployments); setFilteredDeployments([]);
// Uncomment to see mock deployments
// const mockDeployments = [defaultDeployment, secondDeployment];
// setDeployments(mockDeployments);
// setFilteredDeployments(mockDeployments);
// Mock domains // Mock domains
const mockDomains: Domain[] = [ const mockDomains: Domain[] = [
@ -102,6 +114,31 @@ export default function DeploymentsPage() {
setFilteredDeployments(deployments); 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 = { const project = {
id: id, id: id,
prodBranch: 'main', prodBranch: 'main',
@ -110,6 +147,8 @@ export default function DeploymentsPage() {
const currentDeployment = deployments.find(deployment => deployment.isCurrent) || defaultDeployment; const currentDeployment = deployments.find(deployment => deployment.isCurrent) || defaultDeployment;
const hasDeployments = deployments.length > 0;
return ( return (
<PageWrapper <PageWrapper
header={{ header={{
@ -145,41 +184,108 @@ export default function DeploymentsPage() {
</Tabs> </Tabs>
<div className="mt-6"> <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"> <div className="h-full mt-4">
{filteredDeployments.length > 0 ? ( {filteredDeployments.length > 0 ? (
filteredDeployments.map((deployment) => ( filteredDeployments.map((deployment) => (
<div key={deployment.id} className="mb-4">
<DeploymentDetailsCard <DeploymentDetailsCard
key={deployment.id}
deployment={deployment} deployment={deployment}
currentDeployment={currentDeployment} currentDeployment={currentDeployment}
project={project} project={project}
prodBranchDomains={prodBranchDomains} prodBranchDomains={prodBranchDomains}
/> />
)) <div className="mt-2 flex justify-end">
) : ( <Button
<div className="h-96 bg-base-bg-alternate dark:bg-overlay3 rounded-xl flex flex-col items-center justify-center gap-5 text-center"> variant="outline"
<div className="space-y-1"> size="sm"
<p className="font-medium tracking-[-0.011em] text-elements-high-em dark:text-foreground"> onClick={() => handleViewLogs(deployment.id)}
No deployments found >
</p> View logs
<p className="text-sm tracking-[-0.006em] text-elements-mid-em dark:text-foreground-secondary"> </Button>
Please change your search query or filters. </div>
</p> </div>
</div> ))
<IconButton ) : (
// 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>
<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" variant="outline"
size="sm" size="sm"
leftIcon={<Rocket className="w-4 h-4" />}
onClick={handleResetFilters} onClick={handleResetFilters}
> >
RESET FILTERS Reset filters
</IconButton> </Button>
</div> </div>
)} )}
</div> </div>
</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> </PageWrapper>
); );
} }

View File

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

View File

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

View File

@ -1,47 +1,26 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { Clipboard } from "lucide-react"; import { Clipboard } from "lucide-react";
import { Dropdown } from "@/components/core/dropdown"; import { Dropdown } from "@/components/core/dropdown";
import { LoadingOverlay } from "@/components/foundation/loading/loading-overlay"; 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 ProjectSettingsPageProps {
interface ModalProps { project?: Project;
isOpen: boolean; onProjectUpdated?: () => void;
onClose: () => void;
title: string;
children: React.ReactNode;
footer?: React.ReactNode;
} }
function Modal({ isOpen, onClose, title, children, footer }: ModalProps) { export default function ProjectSettingsPage({ project, onProjectUpdated }: ProjectSettingsPageProps) {
if (!isOpen) return null; const client = useGQLClient();
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() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const searchParams = useSearchParams();
const id = params?.id ? String(params.id) : '';
// Use the hook to get repo data
const { repoData, isLoading } = useRepoData(id);
const [projectName, setProjectName] = useState(""); const [projectName, setProjectName] = useState("");
const [projectDescription, setProjectDescription] = useState(""); const [projectDescription, setProjectDescription] = useState("");
@ -51,70 +30,111 @@ export default function ProjectSettingsPage() {
const [isTransferring, setIsTransferring] = useState(false); const [isTransferring, setIsTransferring] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [successMessage, setSuccessMessage] = useState("");
const [errorMessage, setErrorMessage] = useState("");
// Update form values when project data is loaded // Update form values when project data is loaded
useEffect(() => { useEffect(() => {
if (repoData) { if (project) {
setProjectName(repoData.name || ""); setProjectName(project.name || "");
setProjectDescription(repoData.description || ""); setProjectDescription(project.description || "");
setProjectId(repoData.id?.toString() || ""); 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 = [ const accountOptions = [
{ label: "Personal Account", value: "account1" }, { label: "Personal Account", value: "account1" },
{ label: "Team Account", value: "account2" } { 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 () => { const handleSave = async () => {
if (!project) return;
try { try {
setIsSaving(true); setIsSaving(true);
console.log("Saving project info:", { projectName, projectDescription });
// Simulate API call await client.updateProject(project.id, {
await new Promise(resolve => setTimeout(resolve, 1000)); name: projectName,
description: projectDescription
});
// Show success notification - in a real app you'd use a toast library showMessage("Project updated successfully");
console.log("Project updated successfully"); if (onProjectUpdated) {
onProjectUpdated();
}
} catch (error) { } catch (error) {
console.error("Failed to save project info:", error); console.error("Failed to save project info:", error);
// Show error notification showMessage("Failed to update project", true);
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}; };
const handleTransfer = async () => { const handleTransfer = async () => {
if (!project) return;
try { try {
setIsTransferring(true); setIsTransferring(true);
// Transfer project to selected account
// TODO: Implement actual transfer API call when available
console.log("Transferring project to:", selectedAccount); console.log("Transferring project to:", selectedAccount);
// Implement API call to transfer project
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call
// After successful transfer, navigate back to projects list showMessage("Project transfer initiated successfully");
router.push("/dashboard/projects"); // Note: No navigation - staying in the same tab
} catch (error) { } catch (error) {
console.error("Failed to transfer project:", error); console.error("Failed to transfer project:", error);
// Show error notification showMessage("Failed to transfer project", true);
} finally { } finally {
setIsTransferring(false); setIsTransferring(false);
} }
}; };
const handleDelete = async () => { const handleDelete = async () => {
if (!project) return;
try { try {
setIsDeleting(true); 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 await client.deleteProject(project.id);
router.push("/dashboard/projects");
showMessage("Project deleted successfully");
setIsDeleteModalOpen(false);
// Navigate back to projects list after successful deletion
setTimeout(() => {
router.push('/projects');
}, 1500);
} catch (error) { } catch (error) {
console.error("Failed to delete project:", error); console.error("Failed to delete project:", error);
// Show error notification showMessage("Failed to delete project", true);
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
setIsDeleteModalOpen(false); setIsDeleteModalOpen(false);
@ -123,29 +143,15 @@ export default function ProjectSettingsPage() {
const copyToClipboard = (text: string) => { const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
showMessage("Project ID copied to clipboard");
}; };
const DeleteModalFooter = ( if (!project) {
<div className="flex space-x-2"> return (
<button <div className="p-6 text-center text-muted-foreground">
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-md text-white" No project data available
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> </div>
); );
if (isLoading) {
return <LoadingOverlay />;
} }
return ( return (
@ -153,47 +159,61 @@ export default function ProjectSettingsPage() {
{(isSaving || isTransferring || isDeleting) && <LoadingOverlay />} {(isSaving || isTransferring || isDeleting) && <LoadingOverlay />}
<div className="space-y-8 w-full"> <div className="space-y-8 w-full">
<div className="rounded-lg border border-gray-800 p-6 bg-black"> {/* Success/Error Messages */}
<h2 className="text-xl font-semibold mb-4">Project Info</h2> {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 className="space-y-4">
<div> <div>
<label htmlFor="appName" className="block text-sm font-medium mb-1"> <Label htmlFor="appName" className="text-card-foreground">
App name App name
</label> </Label>
<input <Input
id="appName" id="appName"
value={projectName} value={projectName}
onChange={(e) => setProjectName(e.target.value)} 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>
<div> <div>
<label htmlFor="description" className="block text-sm font-medium mb-1"> <Label htmlFor="description" className="text-card-foreground">
Description Description
</label> </Label>
<input <Input
id="description" id="description"
value={projectDescription} value={projectDescription}
onChange={(e) => setProjectDescription(e.target.value)} 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>
<div> <div>
<label htmlFor="projectId" className="block text-sm font-medium mb-1"> <Label htmlFor="projectId" className="text-card-foreground">
Project ID Project ID
</label> </Label>
<div className="relative"> <div className="relative mt-1">
<input <Input
id="projectId" id="projectId"
value={projectId} value={projectId}
readOnly 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 <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)} onClick={() => copyToClipboard(projectId)}
aria-label="Copy project ID" aria-label="Copy project ID"
> >
@ -202,73 +222,132 @@ export default function ProjectSettingsPage() {
</div> </div>
</div> </div>
<button <div>
className="px-4 py-2 border border-gray-600 rounded-md hover:bg-gray-800 transition-colors mt-2" <Label className="text-card-foreground">Repository</Label>
onClick={handleSave} <Input
disabled={isSaving} value={project.repository || 'No repository linked'}
> readOnly
{isSaving ? "Saving..." : "Save"} className="mt-1 bg-muted"
</button> />
</div> </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>
<div> <div>
<label htmlFor="account" className="block text-sm font-medium mb-1"> <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>
</div>
</div>
{/* 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 className="space-y-4">
<div>
<Label htmlFor="account" className="text-card-foreground">
Select account Select account
</label> </Label>
<Dropdown <Dropdown
label="Select" label="Select"
options={accountOptions} options={accountOptions}
selectedValue={selectedAccount} selectedValue={selectedAccount}
onSelect={(value) => setSelectedAccount(value)} onSelect={(value) => setSelectedAccount(value)}
className="w-full" 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. Transfer this app to your personal account or a team you are a member of.
</p> </p>
<button <Button
className="px-4 py-2 border border-gray-600 rounded-md hover:bg-gray-800 transition-colors mt-4" variant="outline"
onClick={handleTransfer} onClick={handleTransfer}
disabled={!selectedAccount || isTransferring} disabled={!selectedAccount || isTransferring}
> >
{isTransferring ? "Transferring..." : "Transfer"} {isTransferring ? "Transferring..." : "Transfer"}
</button> </Button>
</div> </div>
</div> </div>
<div className="rounded-lg border border-gray-800 border-red-900 p-6 bg-black"> {/* Delete Project Section */}
<h2 className="text-xl font-semibold mb-4 text-red-500">Delete Project</h2> <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 The project will be permanently deleted, including its deployments and domains. This action is
irreversible and cannot be undone. irreversible and cannot be undone.
</p> </p>
<button <Button
className="px-4 py-2 bg-red-700 hover:bg-red-800 rounded-md text-white transition-colors" variant="destructive"
onClick={() => setIsDeleteModalOpen(true)} onClick={() => setIsDeleteModalOpen(true)}
> >
Delete project Delete project
</button> </Button>
</div>
</div>
<Modal {/* Delete Confirmation Modal */}
isOpen={isDeleteModalOpen} <Dialog open={isDeleteModalOpen} onOpenChange={(open) => !isDeleting && setIsDeleteModalOpen(open)}>
onClose={() => !isDeleting && setIsDeleteModalOpen(false)} <DialogContent>
title="Are you absolutely sure?" <DialogHeader>
footer={DeleteModalFooter} <DialogTitle>Are you absolutely sure?</DialogTitle>
> </DialogHeader>
<p className="text-gray-300"> <div className="py-4">
This action cannot be undone. This will permanently delete the project <p className="text-foreground">
and all associated deployments and domains. This action cannot be undone. This will permanently delete the project{" "}
<strong>"{project.name}"</strong> and all associated deployments and domains.
</p> </p>
</Modal>
</div>
</div> </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'; 'use client';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { PageWrapper } from "@/components/foundation"; import { PageWrapper } from "@/components/foundation";
import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
import ProjectSettingsPage from "./ProjectSettingsPage"; import ProjectSettingsPage from "./ProjectSettingsPage";
import { useRouter } from 'next/navigation'; 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() { export default function SettingsPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const client = useGQLClient();
// Safely unwrap params // Safely unwrap params
const id = params?.id ? String(params.id) : ''; const id = params?.id ? String(params.id) : '';
const provider = params?.provider ? String(params.provider) : ''; const provider = params?.provider ? String(params.provider) : '';
// Use the hook to get repo data // State for project data
const { repoData } = useRepoData(id); 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 // Handle tab changes by navigating to the correct folder
const handleTabChange = (value: string) => { const handleTabChange = (value: string) => {
@ -40,20 +75,59 @@ export default function SettingsPage() {
} }
}; };
// Show loading state
if (loading) {
return ( return (
<PageWrapper <PageWrapper
header={{ header={{
title: repoData ? `${repoData.name}` : 'Project Settings', 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: project.name || 'Project Settings',
actions: [ actions: [
{ {
label: 'Open repo', label: 'Open repo',
href: repoData?.html_url || '#', href: project.repository || '#',
icon: 'external-link', icon: 'external-link',
external: true external: true
}, },
{ {
label: 'View app', label: 'View app',
href: repoData ? `https://${repoData.name.toLowerCase()}.example.com` : '#', href: project.deployments?.[0]?.applicationDeploymentRecordData?.url || '#',
icon: 'external-link', icon: 'external-link',
external: true external: true
} }
@ -74,9 +148,12 @@ export default function SettingsPage() {
</TabsList> </TabsList>
</Tabs> </Tabs>
{/* Settings content */} {/* Settings content - now with proper project data */}
<div className="mt-6"> <div className="mt-6">
<ProjectSettingsPage /> <ProjectSettingsPage
project={project}
onProjectUpdated={handleProjectUpdated}
/>
</div> </div>
</div> </div>
</PageWrapper> </PageWrapper>

View File

@ -9,119 +9,325 @@ import {
AvatarFallback} from '@workspace/ui/components/avatar'; AvatarFallback} from '@workspace/ui/components/avatar';
import { Button } from '@workspace/ui/components/button'; import { Button } from '@workspace/ui/components/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs'; 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 Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useRepoData } from '@/hooks/useRepoData'; import { useGQLClient } from '@/context';
import { useEffect, useState } from 'react'; 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() { export default function ProjectOverviewPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const client = useGQLClient();
// Safely unwrap params // Safely unwrap params
const id = params?.id ? String(params.id) : ''; const id = params?.id ? String(params.id) : '';
const provider = params?.provider ? String(params.provider) : '';
// Use the hook to get repo data // State management
const { repoData } = useRepoData(id); 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 // Deployment page state
const [deploymentUrl, setDeploymentUrl] = useState(''); const [deployments, setDeployments] = useState<any[]>([]);
const [deploymentDate, setDeploymentDate] = useState(Date.now() - 60 * 60 * 1000); // 1 hour ago const [filteredDeployments, setFilteredDeployments] = useState<any[]>([]);
const [deployedBy, setDeployedBy] = useState(''); const [isLogsOpen, setIsLogsOpen] = useState(false);
const [projectName, setProjectName] = useState(''); const [deploymentLogs, setDeploymentLogs] = useState<string>('');
const [branch, setBranch] = useState('main');
// Update details when repo data is loaded // Load project data
useEffect(() => { useEffect(() => {
if (repoData) { if (id) {
setProjectName(repoData.name); loadProject(id);
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`);
} }
}, [repoData]); }, [id]);
// Auction data // Load project from GraphQL
const auctionId = 'laconic1sdfjwei4jfkasifgjiai45ioasjf5jjjafij355'; const loadProject = async (projectId: string) => {
try {
setLoading(true);
setError(null);
// Activities data const response = await client.getProject(projectId);
const activities = [ setProject(response.project);
{
username: deployedBy || 'username', // Set deployments for the deployment tab
branch: branch, if (response.project?.deployments) {
action: 'deploy: source cargo', setDeployments(response.project.deployments);
time: '5 minutes ago' setFilteredDeployments(response.project.deployments);
},
{
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'
} }
]; } catch (err) {
console.error('Failed to load project:', err);
setError(err instanceof Error ? err.message : 'Failed to load project');
} finally {
setLoading(false);
}
};
// Handle tab changes by navigating to the correct folder // 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) => { const handleTabChange = (value: string) => {
const basePath = `/projects/${provider}/ps/${id}`; setActiveTab(value);
};
switch (value) { // Helper function to safely parse dates
case 'overview': const parseDate = (dateString: string | undefined) => {
router.push(basePath); if (!dateString) return null;
break; const date = new Date(dateString);
case 'deployment': return isNaN(date.getTime()) ? null : date.getTime();
router.push(`${basePath}/dep`); };
break;
case 'settings': // Generate activities from deployments
router.push(`${basePath}/set`); const generateActivities = () => {
break; if (!project?.deployments) return [];
case 'git':
router.push(`${basePath}/int`); return project.deployments
break; .slice(0, 4) // Show last 4 deployments
case 'env-vars': .map((deployment: any) => ({
router.push(`${basePath}/set/env`); username: deployment.createdBy?.name || 'Unknown',
break; 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 ( return (
<PageWrapper <PageWrapper
header={{ header={{
title: projectName || 'Project Overview', 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: project.name || 'Project Overview',
actions: [ actions: [
{ {
label: 'Open repo', label: 'Open repo',
href: repoData?.html_url || '#', href: project.repository || '#',
icon: 'external-link', icon: 'external-link',
external: true external: true
}, },
{ {
label: 'View app', label: 'View app',
href: deploymentUrl || '#', href: currentDeployment?.applicationDeploymentRecordData?.url || latestDeployment?.applicationDeploymentRecordData?.url || '#',
icon: 'external-link', icon: 'external-link',
external: true external: true
} }
] ]
}} }}
layout="bento" // Use bento layout to override max width layout="bento"
className="pb-0" className="pb-0"
> >
<div className="md:col-span-3 w-full"> {/* Take full width in bento grid */} <div className="md:col-span-3 w-full">
{/* Tabs navigation */} {/* Tabs navigation - controlled locally */}
<Tabs defaultValue="overview" className="w-full" onValueChange={handleTabChange}> <Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList> <TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger> <TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="deployment">Deployment</TabsTrigger> <TabsTrigger value="deployment">Deployment</TabsTrigger>
@ -137,13 +343,23 @@ export default function ProjectOverviewPage() {
<div className="p-6"> <div className="p-6">
<div className="flex items-center"> <div className="flex items-center">
<Avatar className="h-10 w-10 mr-4 bg-blue-600"> <Avatar className="h-10 w-10 mr-4 bg-blue-600">
<AvatarFallback>{getInitials(projectName || '')}</AvatarFallback> <AvatarFallback>{getInitials(project.name || '')}</AvatarFallback>
</Avatar> </Avatar>
<div> <div className="flex-1">
<h2 className="text-lg font-medium">{projectName}</h2> <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"> <p className="text-muted-foreground">
{deploymentUrl.replace(/^https?:\/\//, '')} {currentDeployment?.applicationDeploymentRecordData?.url?.replace(/^https?:\/\//, '') ||
latestDeployment?.applicationDeploymentRecordData?.url?.replace(/^https?:\/\//, '') ||
'No deployment URL'}
</p> </p>
{project.description && (
<p className="text-sm text-muted-foreground mt-1">{project.description}</p>
)}
</div> </div>
</div> </div>
@ -151,78 +367,120 @@ export default function ProjectOverviewPage() {
<div> <div>
<div className="flex items-center mb-2"> <div className="flex items-center mb-2">
<GitBranch className="h-4 w-4 mr-2 text-muted-foreground" /> <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>
<div className="flex items-center"> <div className="flex items-center">
<GitBranch className="h-4 w-4 mr-2" /> <GitBranch className="h-4 w-4 mr-2" />
<span>{branch}</span> <span>{project.prodBranch || 'main'}</span>
</div> </div>
</div> </div>
<div> <div>
<div className="flex items-center mb-2"> <div className="flex items-center mb-2">
<ExternalLink className="h-4 w-4 mr-2 text-muted-foreground" /> <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> </div>
{project.repository ? (
<Link <Link
href={deploymentUrl} href={project.repository}
className="text-primary hover:underline flex items-center" className="text-primary hover:underline flex items-center"
target="_blank" target="_blank"
> >
{deploymentUrl} {project.repository.replace('https://github.com/', '')}
<ExternalLink className="h-3 w-3 ml-1" />
</Link> </Link>
) : (
<span className="text-muted-foreground">No repository linked</span>
)}
</div> </div>
</div> </div>
<div className="mt-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
<div>
<div className="flex items-center mb-2"> <div className="flex items-center mb-2">
<Clock className="h-4 w-4 mr-2 text-muted-foreground" /> <Clock className="h-4 w-4 mr-2 text-muted-foreground" />
<span className="text-muted-foreground text-sm">Deployment date</span> <span className="text-muted-foreground text-sm">Last Deployment</span>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<span className="mr-2"> <span className="mr-2">
{relativeTimeMs(deploymentDate)} {latestDeployment ?
(parseDate(latestDeployment.createdAt) ?
relativeTimeMs(parseDate(latestDeployment.createdAt)!) :
'Invalid date') :
'No deployments'
}
</span> </span>
{latestDeployment?.createdBy && (
<>
<span className="mr-2">by</span> <span className="mr-2">by</span>
<Avatar className="h-5 w-5 mr-2"> <Avatar className="h-5 w-5 mr-2">
<AvatarFallback>{getInitials(deployedBy)}</AvatarFallback> <AvatarFallback>{getInitials(latestDeployment.createdBy.name || '')}</AvatarFallback>
</Avatar> </Avatar>
<span>{deployedBy}</span> <span>{latestDeployment.createdBy.name}</span>
</>
)}
</div>
</div>
<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>
</div> </div>
{/* Divider between project info and auction details */} {/* Divider between project info and auction details */}
<div className="border-t border-border my-6"></div> <div className="border-t border-border my-6"></div>
{/* Auction Details section */} {/* Deployment Details section */}
<div> <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 className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<h4 className="text-sm text-muted-foreground mb-1">Auction ID</h4> <h4 className="text-sm text-muted-foreground mb-1">Project ID</h4>
<p className="text-sm font-medium break-all">{auctionId}</p> <p className="text-sm font-medium font-mono break-all">{project.id}</p>
</div> </div>
<div> <div>
<h4 className="text-sm text-muted-foreground mb-1">Auction Status</h4> <h4 className="text-sm text-muted-foreground mb-1">Organization</h4>
<div className="inline-block px-2 py-0.5 bg-green-700/20 text-green-400 text-xs font-medium rounded"> <p className="text-sm font-medium">{project.organization?.name || 'Unknown'}</p>
COMPLETED
</div>
</div> </div>
</div> </div>
{project.auctionId && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
<div> <div>
<h4 className="text-sm text-muted-foreground mb-1">Deployer LRNs</h4> <h4 className="text-sm text-muted-foreground mb-1">Auction ID</h4>
<p className="text-sm font-medium break-all">{auctionId}</p> <p className="text-sm font-medium font-mono break-all">{project.auctionId}</p>
</div> </div>
<div> <div>
<h4 className="text-sm text-muted-foreground mb-1">Deployer Funds Status</h4> <h4 className="text-sm text-muted-foreground mb-1">Funds Status</h4>
<div className="inline-block px-2 py-0.5 bg-blue-700/20 text-blue-400 text-xs font-medium rounded"> <StatusBadge status={project.fundsReleased ? 'RELEASED' : 'PENDING'} />
RELEASED
</div> </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> </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"> <div className="mt-6">
<Button variant="outline" size="sm">View details</Button> <Button variant="outline" size="sm">View details</Button>
@ -231,7 +489,7 @@ export default function ProjectOverviewPage() {
</div> </div>
</div> </div>
{/* Activity section - not in a card */} {/* Activity section */}
<div className="mt-8"> <div className="mt-8">
<h3 className="text-lg font-medium mb-6 flex items-center"> <h3 className="text-lg font-medium mb-6 flex items-center">
<Activity className="mr-2 h-4 w-4" /> <Activity className="mr-2 h-4 w-4" />
@ -239,7 +497,8 @@ export default function ProjectOverviewPage() {
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
{activities.map((activity, index) => ( {activities.length > 0 ? (
activities.map((activity, index) => (
<div key={index} className="flex items-start"> <div key={index} className="flex items-start">
<div className="text-muted-foreground mr-2"></div> <div className="text-muted-foreground mr-2"></div>
<div className="flex-1"> <div className="flex-1">
@ -250,18 +509,300 @@ export default function ProjectOverviewPage() {
</div> </div>
<div className="text-sm text-muted-foreground">{activity.time}</div> <div className="text-sm text-muted-foreground">{activity.time}</div>
</div> </div>
))} ))
) : (
<div className="text-muted-foreground text-center py-8">
No recent activity
</div>
)}
</div> </div>
</div> </div>
</TabsContent> </TabsContent>
{/* These content sections won't be shown - we'll navigate to respective pages instead */} <TabsContent value="deployment" className="pt-6">
<TabsContent value="deployment"></TabsContent> <div className="space-y-6">
<TabsContent value="settings"></TabsContent> {/* Filter Controls */}
<TabsContent value="git"></TabsContent> <div className="flex flex-wrap gap-4">
<TabsContent value="env-vars"></TabsContent> <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> </Tabs>
</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={handleDeleteProject}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageWrapper> </PageWrapper>
); );
} }

View File

@ -1,72 +1,85 @@
'use client' 'use client'
import { PageWrapper } from '@/components/foundation' import { PageWrapper } from '@/components/foundation'
import CheckBalanceIframe from '@/components/iframe/check-balance-iframe/CheckBalanceIframe' 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 { FixedProjectCard } from '@/components/projects/project/ProjectCard/FixedProjectCard'
import { Button } from '@workspace/ui/components/button' import { Button } from '@workspace/ui/components/button'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Shapes } from 'lucide-react' import { Shapes } from 'lucide-react'
import { useAuth, useUser } from '@clerk/nextjs' import { useGQLClient } from '@/context'
import { useRepoData } from '@/hooks/useRepoData' import type { Project } from '@workspace/gql-client'
interface ProjectData { interface ProjectData {
id: string id: string
name: string name: string
icon?: string icon?: string
deployments: any[] deployments: any[]
// Additional fields from GitHub repo repository?: string
full_name?: string framework?: string
html_url?: string description?: string
updated_at?: string
default_branch?: string
} }
export default function ProjectsPage() { export default function ProjectsPage() {
const [, setIsBalanceSufficient] = useState<boolean>() const [, setIsBalanceSufficient] = useState<boolean>()
const [projects, setProjects] = useState<Project[]>([]) const [projects, setProjects] = useState<ProjectData[]>([])
const [isLoading, setIsLoading] = useState<boolean>(true) const [isLoading, setIsLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const { isLoaded: isAuthLoaded, userId } = useAuth() const client = useGQLClient()
const { isLoaded: isUserLoaded, user } = useUser()
// Use the hook to fetch all repos (with an empty ID to get all) const handleCreateProject = () => {
const { repoData: allRepos, isLoading: reposLoading, error: reposError } = useRepoData(''); window.location.href = '/projects/github/ps/cr'
const handleConnectGitHub = () => {
window.open('https://accounts.clerk.dev/user', '_blank');
} }
useEffect(() => { useEffect(() => {
// Process repos data when it's loaded loadAllProjects()
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'
}
}
]
}));
setProjects(projectData); const loadAllProjects = async () => {
setIsLoading(false); try {
} else if (!reposLoading && reposError) { setIsLoading(true)
setError(reposError); setError(null)
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 ( return (
<PageWrapper <PageWrapper
@ -92,16 +105,13 @@ export default function ProjectsPage() {
</div> </div>
<h2 className="text-xl font-semibold mb-2">Error: {error}</h2> <h2 className="text-xl font-semibold mb-2">Error: {error}</h2>
<p className="text-gray-400 text-center max-w-md mb-6"> <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> </p>
<Button <Button
className="bg-white text-black hover:bg-gray-200 flex items-center" 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"> Try Again
<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
</Button> </Button>
</div> </div>
) : projects.length === 0 ? ( ) : projects.length === 0 ? (
@ -114,29 +124,77 @@ export default function ProjectsPage() {
</div> </div>
<h2 className="text-xl font-semibold mb-2">Deploy your first app</h2> <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"> <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> </p>
<Button <Button
className="bg-white text-black hover:bg-gray-200 flex items-center" 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"> <svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<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" /> <line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg> </svg>
Connect to GitHub Create Project
</Button> </Button>
</div> </div>
) : ( ) : (
// Custom grid that spans the entire bento layout // Custom grid that spans the entire bento layout
<div className="md:col-span-3"> <div className="md:col-span-3">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{projects.map((project) => ( {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 <FixedProjectCard
project={project as any} project={formattedProject}
key={project.id} key={project.id}
status={project.deployments[0]?.branch ? 'success' : 'pending'} status={status as any}
/> />
))} )
})}
</div> </div>
</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 type { Metadata } from 'next'
import { Inter } from 'next/font/google' import { Inter } from 'next/font/google'
import { CheckBalanceWrapper } from '@/components/iframe/check-balance-iframe/CheckBalanceWrapper' import { CheckBalanceWrapper } from '@/components/iframe/check-balance-iframe/CheckBalanceWrapper'
import { AutoSignInIFrameModal } from '@/components/iframe/auto-sign-in'
// Add root metadata with template pattern // Add root metadata with template pattern
export const metadata: Metadata = { export const metadata: Metadata = {
@ -29,6 +30,9 @@ export default function RootLayout({
<body className={`${inter.className} `} suppressHydrationWarning> <body className={`${inter.className} `} suppressHydrationWarning>
<main> <main>
<Providers>{children}</Providers> <Providers>{children}</Providers>
<div style={{ display: 'none' }}>
<AutoSignInIFrameModal />
</div>
<CheckBalanceWrapper /> <CheckBalanceWrapper />
</main> </main>
</body> </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, SheetTitle,
SheetTrigger SheetTrigger
} from '@workspace/ui/components/sheet' } 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 Link from 'next/link'
import type React from 'react' import type React from 'react'
import { DarkModeToggle } from '../dark-mode-toggle' import { DarkModeToggle } from '../dark-mode-toggle'
@ -202,10 +202,9 @@ export default function TopNavigation({
config = { config = {
leftItems: [ leftItems: [
{ icon: Shapes, label: 'Projects', href: '/projects' }, { icon: Shapes, label: 'Projects', href: '/projects' },
{ icon: WalletIcon, label: 'Wallet', href: '/wallet' },
{ icon: CreditCard, label: 'Purchase', href: '/purchase' }
], ],
rightItems: [ rightItems: [
{ icon: CreditCard, label: 'Purchase', href: '/purchase' },
{ label: 'Support', href: '/support' }, { label: 'Support', href: '/support' },
{ label: 'Documentation', href: '/documentation' } { label: 'Documentation', href: '/documentation' }
] ]

View File

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

View File

@ -29,8 +29,8 @@
// } // }
'use client' 'use client'
import { useState } from 'react'
import { useWallet } from '@/context/WalletContext' // or WalletContextProvider import { useWallet } from '@/context/WalletContextProvider'
import { Button } from '@workspace/ui/components/button' import { Button } from '@workspace/ui/components/button'
import { import {
DropdownMenu, DropdownMenu,
@ -41,10 +41,17 @@ import {
import { cn } from '@workspace/ui/lib/utils' import { cn } from '@workspace/ui/lib/utils'
import { ChevronDown, LogOut } from 'lucide-react' import { ChevronDown, LogOut } from 'lucide-react'
export function WalletSessionBadge({ className }: { className?: string }) { export function WalletSessionBadge() {
const { wallet, isConnected, connect, disconnect } = useWallet() 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) => { const formatAddress = (address?: string) => {
if (!address) return 'Connect Wallet' if (!address) return 'Connect Wallet'
return `${address.substring(0, 6)}...${address.substring(address.length - 4)}` return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`
@ -58,8 +65,7 @@ export function WalletSessionBadge({ className }: { className?: string }) {
className={cn( className={cn(
'flex items-center space-x-2 rounded-md border px-3 py-1.5 text-sm font-medium', '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', 'hover:bg-accent hover:text-accent-foreground',
'dark:bg-accent/5 dark:hover:bg-accent/10', 'dark:bg-accent/5 dark:hover:bg-accent/10'
className
)} )}
> >
<span className="relative flex h-2 w-2"> <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' isConnected ? 'bg-green-400' : 'bg-red-400'
)} )}
/> />
<span <span
className={cn( className={cn(
'relative inline-flex h-2 w-2 rounded-full', 'relative inline-flex h-2 w-2 rounded-full',
@ -94,7 +101,7 @@ export function WalletSessionBadge({ className }: { className?: string }) {
) : ( ) : (
<DropdownMenuItem <DropdownMenuItem
className="flex cursor-pointer items-center" className="flex cursor-pointer items-center"
onClick={connect} onClick={handleConnect}
> >
<span>Connect Wallet</span> <span>Connect Wallet</span>
</DropdownMenuItem> </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' import { useCallback, useEffect, useState } from 'react'
// Commenting out these imports as they cause linter errors due to missing dependencies import { generateNonce, SiweMessage } from 'siwe'
// In an actual implementation, these would be properly installed import axios from 'axios'
// import { generateNonce, SiweMessage } from 'siwe'
// import axios from 'axios'
// Define proper types to replace 'any' const axiosInstance = axios.create({
interface SiweMessageProps { baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000',
version: string headers: {
domain: string 'Content-Type': 'application/json',
uri: string 'Access-Control-Allow-Origin': '*'
chainId: number },
address: string withCredentials: true
nonce: string })
statement: string
const WALLET_IFRAME_URL = process.env.NEXT_PUBLIC_WALLET_IFRAME_URL || 'http://localhost:4000'
interface AutoSignInProps {
onAuthComplete?: (success: boolean) => void
onClose?: () => void
} }
interface ValidateRequestData { export function AutoSignInIFrameModal({ onAuthComplete, onClose }: AutoSignInProps = {}) {
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>() 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 // Handle sign-in response from the wallet iframe
useEffect(() => { useEffect(() => {
@ -66,26 +216,70 @@ export function AutoSignInIFrameModal() {
if (event.data.type === 'SIGN_IN_RESPONSE') { if (event.data.type === 'SIGN_IN_RESPONSE') {
try { try {
setAuthStatus('signing')
console.log('🔐 Validating SIWE signature...')
const response = await axiosInstance.post('/auth/validate', { const response = await axiosInstance.post('/auth/validate', {
message: event.data.data.message, message: event.data.data.message,
signature: event.data.data.signature signature: event.data.data.signature
}) })
if (response.data.success === true) { if (response.data.success === true) {
// In Next.js, we would use router.push instead console.log('✅ SIWE authentication successful!')
window.location.href = '/' 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) { } catch (error) {
console.error('Error signing in:', error) console.error('❌ Error during SIWE validation:', error)
setAuthStatus('error')
onAuthComplete?.(false)
} }
} }
} }
window.addEventListener('message', handleSignInResponse) window.addEventListener('message', handleSignInResponse)
return () => window.removeEventListener('message', handleSignInResponse)
}, [onAuthComplete, onClose])
return () => { // Listen for wallet accounts data
window.removeEventListener('message', handleSignInResponse) 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 // Initiate auto sign-in when account address is available
@ -93,12 +287,13 @@ export function AutoSignInIFrameModal() {
const initiateAutoSignIn = async () => { const initiateAutoSignIn = async () => {
if (!accountAddress) return if (!accountAddress) return
const iframe = document.getElementById( console.log('🔐 Starting SIWE authentication for:', accountAddress)
'walletAuthFrame'
) as HTMLIFrameElement
if (!iframe.contentWindow) { const iframe = document.getElementById('walletAuthFrame') as HTMLIFrameElement
console.error('Iframe not found or not loaded')
if (!iframe?.contentWindow) {
console.error('❌ walletAuthFrame iframe not found')
setAuthStatus('error')
return return
} }
@ -112,6 +307,8 @@ export function AutoSignInIFrameModal() {
statement: 'Sign in With Ethereum.' statement: 'Sign in With Ethereum.'
}).prepareMessage() }).prepareMessage()
console.log('📝 SIWE message created, requesting signature...')
iframe.contentWindow.postMessage( iframe.contentWindow.postMessage(
{ {
type: 'AUTO_SIGN_IN', type: 'AUTO_SIGN_IN',
@ -122,40 +319,23 @@ export function AutoSignInIFrameModal() {
) )
} }
initiateAutoSignIn() if (accountAddress && authStatus === 'connecting') {
}, [accountAddress]) setTimeout(initiateAutoSignIn, 500)
// 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)
} }
} }, [accountAddress, authStatus])
window.addEventListener('message', handleAccountsDataResponse)
return () => {
window.removeEventListener('message', handleAccountsDataResponse)
}
}, [])
// Request wallet address when iframe is loaded // Request wallet address when iframe is loaded
const getAddressFromWallet = useCallback(() => { const getAddressFromWallet = useCallback(() => {
const iframe = document.getElementById( const iframe = document.getElementById('walletAuthFrame') as HTMLIFrameElement
'walletAuthFrame'
) as HTMLIFrameElement
if (!iframe.contentWindow) { if (!iframe?.contentWindow) {
console.error('Iframe not found or not loaded') console.error('❌ Iframe not found or not loaded')
return return
} }
console.log('📤 Requesting wallet address for SIWE...')
setAuthStatus('idle')
iframe.contentWindow.postMessage( iframe.contentWindow.postMessage(
{ {
type: 'REQUEST_CREATE_OR_GET_ACCOUNTS', type: 'REQUEST_CREATE_OR_GET_ACCOUNTS',
@ -165,9 +345,61 @@ export function AutoSignInIFrameModal() {
) )
}, []) }, [])
// Don't render if not visible
if (!isVisible) return null
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> <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"> <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 <iframe
onLoad={getAddressFromWallet} onLoad={getAddressFromWallet}
id="walletAuthFrame" id="walletAuthFrame"

View File

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

View File

@ -1,23 +1,49 @@
// src/components/onboarding/configure-step/configure-step.tsx
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { PlusCircle } from 'lucide-react' import { PlusCircle, Loader2, AlertTriangle, Info } from 'lucide-react'
import { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { useOnboarding } from '@/components/onboarding/useOnboarding' import { useOnboarding } from '@/components/onboarding/useOnboarding'
import { useGQLClient } from '@/context'
import { useWallet } from '@/context/WalletContext'
import { Button } from '@workspace/ui/components/button' import { Button } from '@workspace/ui/components/button'
import { Input } from '@workspace/ui/components/input' import { Input } from '@workspace/ui/components/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@workspace/ui/components/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@workspace/ui/components/select'
import { Checkbox } from '@workspace/ui/components/checkbox' import { Checkbox } from '@workspace/ui/components/checkbox'
import { Label } from '@workspace/ui/components/label' 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() { export function ConfigureStep() {
const { nextStep, previousStep, setFormData, formData } = useOnboarding() const { nextStep, previousStep, setFormData, formData } = useOnboarding()
const { resolvedTheme } = useTheme() const { resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false) 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 // Form state
const [deployOption, setDeployOption] = useState<'auction' | 'lrn'>( 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>( const [numberOfDeployers, setNumberOfDeployers] = useState<string>(
formData.deployerCount || "1" formData.deployerCount || "1"
@ -28,38 +54,115 @@ export function ConfigureStep() {
const [selectedLrn, setSelectedLrn] = useState<string>( const [selectedLrn, setSelectedLrn] = useState<string>(
formData.selectedLrn || "" formData.selectedLrn || ""
) )
const [selectedAccount, setSelectedAccount] = useState<string>("") const [selectedOrg, setSelectedOrg] = useState<string>(
const [environments, setEnvironments] = useState<{ formData.selectedOrg || ""
production: boolean, )
preview: boolean, const [envVars, setEnvVars] = useState<{ key: string; value: string; environments: string[] }[]>([
development: boolean { key: '', value: '', environments: ['Production'] }
}>(formData.environments || {
production: false,
preview: false,
development: false
})
const [envVars, setEnvVars] = useState<{ key: string; value: string }[]>([
{ key: '', value: '' }
]) ])
// Contexts
const gqlClient = useGQLClient()
const { wallet } = useWallet()
// Handle hydration mismatch by waiting for mount // Handle hydration mismatch by waiting for mount
useEffect(() => { useEffect(() => {
setMounted(true) setMounted(true)
}, []) }, [])
// Fetch deployers and organizations on mount
useEffect(() => {
if (mounted) {
fetchDeployers()
fetchOrganizations()
}
}, [mounted])
// Initialize environment variables from formData if available // Initialize environment variables from formData if available
useEffect(() => { useEffect(() => {
if (formData.environmentVariables) { if (formData.environmentVariables && Array.isArray(formData.environmentVariables)) {
const vars: { key: string; value: string }[] = Object.entries(formData.environmentVariables).map( setEnvVars(formData.environmentVariables.length > 0 ? formData.environmentVariables : [
([key, value]) => ({ key, value }) { key: '', value: '', environments: ['Production'] }
) ])
setEnvVars(vars.length > 0 ? vars : [{ key: '', value: '' }])
} }
}, [formData.environmentVariables]) }, [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 // Add an empty environment variable row
const addEnvVar = () => { 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 // Toggle deployment option
@ -67,29 +170,36 @@ export function ConfigureStep() {
setDeployOption(option) setDeployOption(option)
} }
// Toggle environment checkbox // Get selected deployer details
const toggleEnvironment = (env: 'production' | 'preview' | 'development') => { const selectedDeployer = deployers.find(d => d.deployerLrn === selectedLrn)
setEnvironments({
...environments, // Validate form
[env]: !environments[env] const canProceed = () => {
}) if (deployOption === 'lrn' && !selectedLrn) return false
if (!selectedOrg) return false
if (!wallet?.address) return false
return true
} }
// Handle next step // Handle next step
const handleNext = () => { 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 // Save configuration to form data
setFormData({ setFormData({
deploymentType: deployOption, deploymentType: deployOption,
deployerCount: numberOfDeployers, deployerCount: numberOfDeployers,
maxPrice: maxPrice, maxPrice: maxPrice,
selectedLrn: selectedLrn, selectedLrn: selectedLrn,
environments: environments, selectedOrg: selectedOrg,
environmentVariables: envVars.reduce((acc, { key, value }) => { paymentAddress: wallet?.address,
if (key && value) { environmentVariables: validEnvVars
acc[key] = value
}
return acc
}, {} as Record<string, string>)
}) })
nextStep() nextStep()
@ -103,6 +213,10 @@ export function ConfigureStep() {
// Determine if dark mode is active // Determine if dark mode is active
const isDarkMode = resolvedTheme === 'dark' const isDarkMode = resolvedTheme === 'dark'
// Get deployment mode info
const isTemplateMode = formData.deploymentMode === 'template'
const selectedItem = isTemplateMode ? formData.template?.name : formData.githubRepo
return ( return (
<div className="w-full h-full flex flex-col p-8 overflow-y-auto"> <div className="w-full h-full flex flex-col p-8 overflow-y-auto">
{/* Configure icon and header */} {/* Configure icon and header */}
@ -115,22 +229,76 @@ export function ConfigureStep() {
</div> </div>
<h2 className={`text-2xl font-medium text-center mb-2 ${isDarkMode ? "text-white" : "text-zinc-900"}`}>Configure</h2> <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`}> <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> </p>
</div> </div>
<div className="max-w-xl mx-auto w-full"> <div className="max-w-xl mx-auto w-full">
{/* 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 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 */} {/* Deployment options */}
<div className="grid grid-cols-2 gap-2 mb-6"> <div className="mb-6">
<Button <Label className={`text-sm mb-3 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
variant={deployOption === 'auction' ? "default" : "outline"} Deployment Type
className={`py-3 ${deployOption === 'auction' </Label>
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white') <div className="grid grid-cols-2 gap-2">
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`}
onClick={() => toggleDeployOption('auction')}
>
Create Auction
</Button>
<Button <Button
variant={deployOption === 'lrn' ? "default" : "outline"} variant={deployOption === 'lrn' ? "default" : "outline"}
className={`py-3 ${deployOption === 'lrn' className={`py-3 ${deployOption === 'lrn'
@ -140,11 +308,75 @@ export function ConfigureStep() {
> >
Deployer LRN Deployer LRN
</Button> </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> </div>
{deployOption === 'auction' ? ( {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>
) : (
<> <>
{/* Auction settings */} <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>
) : (
/* Auction Settings */
<div className="grid grid-cols-2 gap-4 mb-6"> <div className="grid grid-cols-2 gap-4 mb-6">
<div> <div>
<Label htmlFor="deployers" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}> <Label htmlFor="deployers" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
@ -180,55 +412,68 @@ export function ConfigureStep() {
</Select> </Select>
</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
</Label>
<Select value={selectedLrn} onValueChange={setSelectedLrn}>
<SelectTrigger id="lrn" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
<SelectValue placeholder="Select" />
</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>
</SelectContent>
</Select>
</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 */} {/* Environment Variables */}
<div className="mb-6"> <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'}`}> <div className={`border rounded-md p-4 ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}>
{envVars.map((envVar, index) => ( {envVars.map((envVar, index) => (
<div key={index} className="grid grid-cols-2 gap-2 mb-2"> <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 <Input
placeholder="KEY" placeholder="KEY"
value={envVar.key} value={envVar.key}
onChange={(e) => { onChange={(e) => updateEnvVar(index, 'key', e.target.value)}
const newEnvVars = [...envVars];
newEnvVars[index].key = e.target.value;
setEnvVars(newEnvVars);
}}
className={`bg-transparent ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`} className={`bg-transparent ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}
/> />
<Input <Input
placeholder="VALUE" placeholder="VALUE"
value={envVar.value} value={envVar.value}
onChange={(e) => { onChange={(e) => updateEnvVar(index, 'value', e.target.value)}
const newEnvVars = [...envVars];
newEnvVars[index].value = e.target.value;
setEnvVars(newEnvVars);
}}
className={`bg-transparent ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`} className={`bg-transparent ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}
/> />
</div> </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 <Button
variant="outline" variant="outline"
@ -241,62 +486,6 @@ export function ConfigureStep() {
</div> </div>
</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 */} {/* Navigation buttons */}
<div className="flex justify-between items-center mt-4"> <div className="flex justify-between items-center mt-4">
<Button <Button
@ -310,6 +499,7 @@ export function ConfigureStep() {
variant="default" variant="default"
className={`${isDarkMode ? 'bg-zinc-800' : 'bg-zinc-800'} text-white hover:bg-zinc-700`} className={`${isDarkMode ? 'bg-zinc-800' : 'bg-zinc-800'} text-white hover:bg-zinc-700`}
onClick={handleNext} onClick={handleNext}
disabled={!canProceed()}
> >
Next Next
</Button> </Button>

View File

@ -1,169 +1,449 @@
// src/components/onboarding/connect-step/connect-step.tsx
'use client' 'use client'
import { useState, useEffect } from 'react' 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 { useTheme } from 'next-themes'
import { SignIn } from '@clerk/nextjs'
import { useOnboarding } from '@/components/onboarding/useOnboarding' import { useOnboarding } from '@/components/onboarding/useOnboarding'
import { Button } from '@workspace/ui/components/button' import { useAuthStatus } from '@/hooks/useAuthStatus'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@workspace/ui/components/select'
import { useRepoData } from '@/hooks/useRepoData' 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 { interface Repository {
id: string | number id: string | number
full_name: string full_name: string
html_url?: string html_url?: string
description?: string
} }
export function ConnectStep() { export function ConnectStep() {
const { nextStep, setFormData, formData } = useOnboarding() const { nextStep, setFormData, formData } = useOnboarding()
const { resolvedTheme } = useTheme() const { resolvedTheme } = useTheme()
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
// Repository vs Template selection
const [selectedRepo, setSelectedRepo] = useState<string>(formData.githubRepo || '') 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 [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 // Handle hydration mismatch by waiting for mount
useEffect(() => { useEffect(() => {
setMounted(true) setMounted(true)
}, []) }, [])
// Auto-hide auth warning when fully authenticated
useEffect(() => {
if (isFullyAuthenticated) {
setShowAuthWarning(false)
}
}, [isFullyAuthenticated])
// Handle repository selection // Handle repository selection
const handleRepoSelect = (repo: string) => { const handleRepoSelect = (repo: string) => {
setSelectedRepo(repo) 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 // Handle mode toggle between import and template
const toggleMode = (mode: 'import' | 'template') => { const toggleMode = (mode: 'import' | 'template') => {
setIsImportMode(mode === 'import') setIsImportMode(mode === 'import')
// 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 // Handle next step
const handleNext = () => { const handleNext = () => {
if (selectedRepo || !isImportMode) { if (!isFullyAuthenticated) {
nextStep() 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 // Don't render UI until after mount to prevent hydration mismatch
if (!mounted) { if (!mounted || !isReady) {
return null 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 // Determine if dark mode is active
const isDarkMode = resolvedTheme === 'dark' const isDarkMode = resolvedTheme === 'dark'
return ( return (
<div className="w-full h-full flex flex-col items-center justify-center p-8"> <div className="w-full h-full flex flex-col p-8 overflow-y-auto">
<div className="max-w-md w-full mx-auto"> <div className="max-w-2xl w-full mx-auto">
{/* Connect icon */} {/* Header */}
<div className="mx-auto mb-6 flex justify-center"> <div className="text-center mb-8">
<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"}> <h2 className={`text-2xl font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} mb-2`}>
<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"/> Connect
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> </h2>
</svg> <p className="text-zinc-500 mb-6">
</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 Connect and import a GitHub repo or start from a template
</p> </p>
{/* Git account selector */} {/* GitHub Account Selector - Only show if multiple accounts */}
<div className="mb-4"> {clerk.user?.externalAccounts && clerk.user.externalAccounts.length > 1 && (
<Select defaultValue="git-account"> <div className="flex items-center justify-center mb-6">
<SelectTrigger className={`w-full py-3 ${isDarkMode ? "border-zinc-700" : "border-zinc-300"}`}> <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">
<div className="flex items-center"> <Github className="h-4 w-4" />
<Github className="mr-2 h-5 w-5" /> <span className="text-sm font-medium">
<SelectValue placeholder="Select Git account" /> {clerk.user?.externalAccounts?.find(acc => acc.provider === 'github')?.username || 'git-account'}
</span>
<ChevronDown className="h-4 w-4" />
</div> </div>
</SelectTrigger> </div>
<SelectContent> )}
<SelectItem value="git-account">git-account</SelectItem>
</SelectContent>
</Select>
</div> </div>
{/* Mode buttons */} {/* Authentication Warning - Only show if not fully authenticated */}
<div className="grid grid-cols-2 gap-2 mb-4"> {!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 <Button
variant={isImportMode ? "default" : "outline"} variant={isImportMode ? "default" : "ghost"}
className={`py-3 ${isImportMode className={`${isImportMode
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white') ? 'bg-white dark:bg-zinc-700 shadow-sm'
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`} : 'bg-transparent hover:bg-white/50 dark:hover:bg-zinc-700/50'
}`}
onClick={() => toggleMode('import')} onClick={() => toggleMode('import')}
> >
Import a repository Import a repository
</Button> </Button>
<Button <Button
variant={!isImportMode ? "default" : "outline"} variant={!isImportMode ? "default" : "ghost"}
className={`py-3 ${!isImportMode className={`${!isImportMode
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white') ? 'bg-white dark:bg-zinc-700 shadow-sm'
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`} : 'bg-transparent hover:bg-white/50 dark:hover:bg-zinc-700/50'
}`}
onClick={() => toggleMode('template')} onClick={() => toggleMode('template')}
> >
Start with a template Start with a template
</Button> </Button>
</div> </div>
{/* Repository or template list */} {/* Content Area */}
{isImportMode ? ( {isImportMode ? (
<div className={`border rounded-md overflow-hidden mb-4 ${isDarkMode ? "border-zinc-700" : "border-zinc-300"}`}> /* Repository Selection */
{isLoading ? ( <div className="space-y-4">
<div className="p-6 text-center text-zinc-500"> {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> <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... Loading repositories...
</div> </div>
) : !repositories || repositories.length === 0 ? ( ) : !repositories || repositories.length === 0 ? (
<div className="p-6 text-center text-zinc-500"> <div className="p-8 text-center text-zinc-500">
No repositories found <Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
No repositories found. Make sure your GitHub account has repositories.
</AlertDescription>
</Alert>
</div> </div>
) : ( ) : (
<div className="max-h-60 overflow-y-auto"> <>
<div className="space-y-2 max-h-60 overflow-y-auto">
{repositories.map((repo: Repository) => ( {repositories.map((repo: Repository) => (
<div <div
key={repo.id} key={repo.id}
className={`flex items-center p-4 ${isDarkMode ? "border-zinc-700" : "border-zinc-200"} border-b last:border-b-0 cursor-pointer ${ className={`flex items-center p-4 rounded-lg border cursor-pointer transition-all ${
selectedRepo === repo.full_name selectedRepo === repo.full_name
? (isDarkMode ? 'bg-zinc-800' : 'bg-zinc-100') ? 'border-blue-500 bg-blue-50 dark:bg-blue-950/20'
: (isDarkMode ? 'hover:bg-zinc-800' : 'hover:bg-zinc-50') : 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600'
}`} }`}
onClick={() => handleRepoSelect(repo.full_name)} onClick={() => handleRepoSelect(repo.full_name)}
> >
<div className={`flex-1 ${isDarkMode ? "text-white" : "text-zinc-900"}`}> <Github className="h-5 w-5 mr-3 text-zinc-500 flex-shrink-0" />
<Github className="inline-block h-4 w-4 mr-2 text-zinc-500" /> <div className="flex-1 min-w-0">
<span>{repo.full_name}</span> <div className="font-medium text-sm">{repo.full_name}</div>
</div> {repo.description && (
<div className="text-sm text-zinc-500"> <div className="text-xs text-zinc-500 truncate">{repo.description}</div>
5 minutes ago )}
</div> </div>
{selectedRepo === repo.full_name && (
<CheckCircle2 className="h-5 w-5 text-blue-500 flex-shrink-0" />
)}
</div> </div>
))} ))}
</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"}`}> /* Template Selection */
<div className="p-6 text-center text-zinc-500"> <div className="space-y-4">
Template selection coming soon {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>
</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"> <div className="flex justify-between items-center mt-8">
<Button <Button variant="outline" disabled>
variant="outline"
className={`${isDarkMode ? "text-zinc-400 border-zinc-700" : "text-zinc-600 border-zinc-300"} bg-transparent`}
disabled={true}
>
Previous Previous
</Button> </Button>
<Button <Button
className={`${isDarkMode ? 'bg-zinc-800' : 'bg-zinc-800'} text-white hover:bg-zinc-700`}
onClick={handleNext} onClick={handleNext}
disabled={!selectedRepo && isImportMode} disabled={!isFullyAuthenticated || (isImportMode ? !selectedRepo : (!selectedTemplate || !projectName.trim()))}
> >
Next Next
</Button> </Button>

View File

@ -1,12 +1,21 @@
// src/components/onboarding/deploy-step/deploy-step.tsx
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useTheme } from 'next-themes' 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 { 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 { Button } from '@workspace/ui/components/button'
import { Progress } from '@workspace/ui/components/progress' import { Progress } from '@workspace/ui/components/progress'
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogFooter } from '@workspace/ui/components/dialog' 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() { export function DeployStep() {
const { previousStep, nextStep, formData, setFormData } = useOnboarding() const { previousStep, nextStep, formData, setFormData } = useOnboarding()
@ -14,21 +23,67 @@ export function DeployStep() {
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
// State // State
const [isDeploying, setIsDeploying] = useState(false)
const [deploymentProgress, setDeploymentProgress] = useState(0)
const [showConfirmDialog, setShowConfirmDialog] = useState(false) 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 // Handle hydration mismatch by waiting for mount
useEffect(() => { useEffect(() => {
setMounted(true) setMounted(true)
}, []) }, [])
// Repository information from previous steps // Get deployment info
const repoFullName = formData.githubRepo || 'git-account/repo-name' const getDeploymentInfo = () => {
const branch = 'main' 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 // Open the confirmation modal
const handlePayAndDeploy = () => { 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) setShowConfirmDialog(true)
} }
@ -38,42 +93,91 @@ export function DeployStep() {
} }
// Handle confirmed deployment // Handle confirmed deployment
const handleConfirmDeploy = () => { const handleConfirmDeploy = async () => {
setShowConfirmDialog(false) 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 // Deploy template project
const startDeployment = () => { const deployTemplateProject = async () => {
setIsDeploying(true) if (!formData.template || !formData.projectName || !formData.selectedOrg) {
throw new Error('Missing required template deployment data')
}
// Simulate deployment process with progress updates const config = {
let progress = 0 template: formData.template,
const interval = setInterval(() => { projectName: formData.projectName,
progress += 10 organizationSlug: formData.selectedOrg,
setDeploymentProgress(progress) environmentVariables: formData.environmentVariables || [],
deployerLrn: formData.selectedLrn
}
if (progress >= 100) { console.log('Deploying template with config:', config)
clearInterval(interval)
// Generate deployment ID and create URL const result = await deployTemplate(config)
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 // Save deployment results
setFormData({ setFormData({
deploymentId, deploymentId: result.deploymentId,
deploymentUrl: `https://${repoName}.laconic.deploy`, deploymentUrl: result.deploymentUrl,
projectId projectId: result.projectId,
repositoryUrl: result.repositoryUrl
}) })
setDeploymentSuccess(true)
toast.success('Template deployed successfully!')
// Move to success step after short delay // Move to success step after short delay
setTimeout(() => { setTimeout(() => {
nextStep() nextStep()
}, 500) }, 2000)
} }
}, 500)
// 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 // Don't render UI until after mount to prevent hydration mismatch
@ -101,41 +205,92 @@ export function DeployStep() {
</div> </div>
{/* Deploy header */} {/* 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"> <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> </p>
{/* Repository info */} {/* Deployment Summary */}
<div className={`border rounded-lg overflow-hidden mb-8 ${isDarkMode ? "border-zinc-700" : "border-zinc-300"}`}> <Card className="mb-6">
<div className="p-5 flex items-center"> <CardHeader className="pb-3">
<div className="mr-3"> <CardTitle className="text-sm flex items-center justify-between">
<Github className="h-5 w-5 text-zinc-500" /> Deployment Summary
</div> <Badge variant="secondary">{deploymentInfo.type}</Badge>
<div className="flex-1"> </CardTitle>
<div className={isDarkMode ? "text-white" : "text-zinc-900"}>{repoFullName}</div> </CardHeader>
<div className="text-sm text-zinc-500"> <CardContent className="pt-0 space-y-3">
<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"> <div className="flex items-center gap-3">
<polyline points="6 9 12 15 18 9"></polyline> <Github className="h-4 w-4 text-muted-foreground" />
</svg> <div className="flex-1 min-w-0">
{branch} <div className="font-medium text-sm">{deploymentInfo.projectName}</div>
</div> <div className="text-xs text-muted-foreground font-mono">
{deploymentInfo.source}
</div> </div>
</div> </div>
</div> </div>
{/* Deployment progress */} <div className="grid grid-cols-2 gap-4 pt-2 text-xs">
{isDeploying && deploymentProgress > 0 && ( <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>
{/* 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="mb-8">
<div className="flex justify-between items-center mb-2"> <div className="flex justify-between items-center mb-2">
<div className={`${isDarkMode ? "text-white" : "text-zinc-900"} text-sm`}> <div className={`${isDarkMode ? "text-white" : "text-zinc-900"} text-sm`}>
{deploymentProgress < 30 && "Preparing deployment..."} {isTemplateMode ? 'Creating repository from template...' : 'Deploying repository...'}
{deploymentProgress >= 30 && deploymentProgress < 90 && "Deploying your project..."}
{deploymentProgress >= 90 && "Finalizing deployment..."}
</div> </div>
<div className="text-zinc-500 text-xs">{deploymentProgress}%</div>
</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> </div>
)} )}
@ -145,12 +300,20 @@ export function DeployStep() {
variant="outline" variant="outline"
className={`${isDarkMode ? "text-zinc-400 border-zinc-700" : "text-zinc-600 border-zinc-300"} bg-transparent`} className={`${isDarkMode ? "text-zinc-400 border-zinc-700" : "text-zinc-600 border-zinc-300"} bg-transparent`}
onClick={previousStep} onClick={previousStep}
disabled={isDeploying} disabled={isDeploying || deploymentSuccess}
> >
Previous Previous
</Button> </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 <Button
className={`${isDarkMode ? "bg-zinc-700 text-zinc-300" : "bg-zinc-300 text-zinc-600"}`} className={`${isDarkMode ? "bg-zinc-700 text-zinc-300" : "bg-zinc-300 text-zinc-600"}`}
disabled disabled
@ -162,8 +325,9 @@ export function DeployStep() {
<Button <Button
className="bg-blue-600 hover:bg-blue-700 text-white flex items-center" className="bg-blue-600 hover:bg-blue-700 text-white flex items-center"
onClick={handlePayAndDeploy} 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"> <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"/> <path d="M5 12H19M19 12L13 6M19 12L13 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg> </svg>
@ -175,69 +339,79 @@ export function DeployStep() {
{/* Transaction Confirmation Dialog */} {/* Transaction Confirmation Dialog */}
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}> <Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<DialogContent className="bg-black border-zinc-800 text-white max-w-md"> <DialogContent className="bg-background border max-w-md">
<DialogTitle className="text-white">Confirm Transaction</DialogTitle> <DialogTitle>Confirm Deployment</DialogTitle>
<DialogDescription className="text-zinc-400"> <DialogDescription>
This is a dialog description. Review the deployment details before proceeding.
</DialogDescription> </DialogDescription>
<div className="space-y-6 py-4"> <div className="space-y-4 py-4">
{/* From */} {/* Project Info */}
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-lg font-medium text-white">From</h3> <h3 className="text-sm font-medium">Project Details</h3>
<div className="space-y-1"> <div className="space-y-1 text-sm">
<div className="text-sm text-zinc-400">Address</div> <div className="flex justify-between">
<div className="text-sm text-white break-all font-mono">laconic1sdfjwel4jfkasfjgjal45ioasjj5jjlajfjj355</div> <span className="text-muted-foreground">Name:</span>
<span>{deploymentInfo.projectName}</span>
</div> </div>
<div className="space-y-1"> <div className="flex justify-between">
<div className="text-sm text-zinc-400">Public Key</div> <span className="text-muted-foreground">Type:</span>
<div className="text-sm text-white break-all font-mono">laconic1sdfjwel4jfkasfjgjal45ioasjj5jjlajfjj355</div> <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 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>
</div> </div>
</div> </div>
{/* Balance */} {/* Wallet Info */}
<div className="space-y-1"> <div className="space-y-2">
<div className="text-lg font-medium text-white">Balance</div> <h3 className="text-sm font-medium">Payment Address</h3>
<div className="text-lg text-white">129600</div> <div className="p-2 bg-muted rounded text-xs font-mono break-all">
{wallet?.address}
</div>
</div> </div>
{/* To */} {/* Deployer Info */}
<div className="space-y-1"> {formData.selectedLrn && (
<div className="text-lg font-medium text-white">To</div> <div className="space-y-2">
<div className="text-sm text-white break-all font-mono">laconic1sdfjwel4jfkasfjgjal45ioasjj5jjlajfjj355</div> <h3 className="text-sm font-medium">Deployer</h3>
<div className="text-sm">
<div className="font-mono text-xs">{formData.selectedLrn}</div>
</div> </div>
</div>
)}
{/* Amount */} {/* Cost Info */}
<div className="space-y-1"> {formData.deploymentType === 'auction' && (
<div className="text-lg font-medium text-white">Amount</div> <div className="space-y-2">
<div className="space-y-1"> <h3 className="text-sm font-medium">Auction Details</h3>
<div className="text-sm text-zinc-400">Balance (aint)</div> <div className="space-y-1 text-sm">
<div className="text-sm text-white">129600</div> <div className="flex justify-between">
<span className="text-muted-foreground">Max Price:</span>
<span>{formData.maxPrice} aint</span>
</div> </div>
<div className="space-y-1"> <div className="flex justify-between">
<div className="text-sm text-zinc-400">Amount (aint)</div> <span className="text-muted-foreground">Deployers:</span>
<div className="text-sm text-white">3000</div> <span>{formData.deployerCount}</span>
</div> </div>
</div> </div>
</div> </div>
)}
</div>
<DialogFooter className="flex justify-end space-x-2"> <DialogFooter className="flex justify-end space-x-2">
<Button <Button
variant="outline" variant="outline"
className="text-zinc-400 bg-zinc-900 border-zinc-800 hover:bg-zinc-800"
onClick={handleCancelConfirm} onClick={handleCancelConfirm}
> >
No, cancel Cancel
</Button> </Button>
<Button <Button
className="bg-white text-black hover:bg-white/90"
onClick={handleConfirmDeploy} onClick={handleConfirmDeploy}
> >
Yes, confirm Confirm Deployment
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -6,7 +6,6 @@ import { useTheme } from 'next-themes'
import { CheckCircle } from 'lucide-react' import { CheckCircle } from 'lucide-react'
import { Button } from '@workspace/ui/components/button' import { Button } from '@workspace/ui/components/button'
import { useOnboarding } from '@/components/onboarding/useOnboarding' import { useOnboarding } from '@/components/onboarding/useOnboarding'
import { toast } from 'sonner'
export function SuccessStep() { export function SuccessStep() {
const router = useRouter() const router = useRouter()
@ -22,20 +21,30 @@ export function SuccessStep() {
}, []) }, [])
// Get deployment info from form data // 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 deploymentUrl = formData.deploymentUrl || `https://${repoName}.laconic.deploy`
const projectId = formData.projectId || 'unknown-id' const projectId = formData.projectId || 'unknown-id'
// Function to copy URL to clipboard
// Handle "Visit Site" button
// Handle "View Project" button - navigates to project page // Handle "View Project" button - navigates to project page
const handleViewProject = () => { const handleViewProject = () => {
console.log('Navigating to project with ID:', projectId)
resetOnboarding() // Reset state for next time resetOnboarding() // Reset state for next time
// Navigate to the project detail page using the GraphQL project ID
router.push(`/projects/${providerParam}/ps/${projectId}`) 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 // Don't render UI until after mount to prevent hydration mismatch
if (!mounted) { if (!mounted) {
return null return null
@ -54,12 +63,30 @@ export function SuccessStep() {
{/* Success header */} {/* Success header */}
<h2 className={`text-2xl font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} text-center mb-2`}> <h2 className={`text-2xl font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} text-center mb-2`}>
Successfully Successfully Deployed!
</h2> </h2>
<p className="text-center text-zinc-500 mb-8"> <p className="text-center text-zinc-500 mb-8">
Your auction was successfully created Your project has been deployed successfully
</p> </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 */} {/* Next steps section */}
<div className="mb-8"> <div className="mb-8">
<h3 className={`text-lg font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} mb-4`}>Next steps</h3> <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 <Button
className="w-full bg-white hover:bg-white/90 text-black flex items-center justify-center" className="w-full bg-white hover:bg-white/90 text-black flex items-center justify-center"
onClick={handleViewProject} onClick={handleViewProject}
disabled={!projectId || projectId === 'unknown-id'}
> >
View Project View Project
<svg className="ml-2 h-4 w-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <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"/> <path d="M5 12H19M19 12L13 6M19 12L13 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg> </svg>
</Button> </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> </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 { useRouter } from 'next/navigation'
import React, { type ComponentPropsWithoutRef, useCallback } from 'react' import React, { type ComponentPropsWithoutRef, useCallback } from 'react'
import { Card, CardContent, CardHeader } from '@workspace/ui/components/card' import { Card, CardContent, CardHeader } from '@workspace/ui/components/card'
@ -9,7 +11,6 @@ import {
AvatarFallback, AvatarFallback,
AvatarImage AvatarImage
} from '@workspace/ui/components/avatar' } from '@workspace/ui/components/avatar'
import router from 'next/router'
/** /**
* Status types for project deployment status * Status types for project deployment status
@ -92,6 +93,15 @@ const ProjectCardActions = ({
setMenuOpen(!menuOpen); 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 ( return (
<div className="relative"> <div className="relative">
<button <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 * FixedProjectCard component
*/ */
@ -155,19 +159,21 @@ export const FixedProjectCard = ({
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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] [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( const handleDeleteClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); 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] [project.id, router]
); );
@ -215,15 +221,6 @@ export const FixedProjectCard = ({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ProjectStatusDot status={status} /> <ProjectStatusDot status={status} />
<ProjectDeploymentInfo project={project} /> <ProjectDeploymentInfo project={project} />
{/* <div className="mt-4 flex justify-end">
<Button
onClick={() => startDeployment(project)}
variant="default"
size="sm"
>
Deploy
</Button>
</div> */}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -1,10 +1,12 @@
// src/components/providers.tsx
'use client' 'use client'
import { ThemeProvider } from 'next-themes' import { ThemeProvider } from 'next-themes'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import '@workspace/ui/globals.css' import '@workspace/ui/globals.css'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
import { OctokitProviderWithRouter } from '@/context/OctokitProviderWithRouter' 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 { GQLClientProvider } from '@/context'
import { GQLClient } from '@workspace/gql-client' import { GQLClient } from '@workspace/gql-client'
@ -16,10 +18,8 @@ export function Providers({ children }: { children: React.ReactNode }) {
// Initialize GQLClient // Initialize GQLClient
const initGQLClient = async () => { const initGQLClient = async () => {
try { try {
// Create a new instance of GQLClient
const client = new GQLClient({ const client = new GQLClient({
endpoint: process.env.NEXT_PUBLIC_GQL_ENDPOINT || 'https://api.snowballtools-base.example', gqlEndpoint: 'http://localhost:8000/graphql',
// Add any auth headers or other configuration needed
}) })
setGqlClient(client) setGqlClient(client)
} catch (error) { } catch (error) {
@ -32,8 +32,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
initGQLClient() initGQLClient()
}, []) }, [])
if (isLoading) { if (isLoading || !gqlClient) {
// You might want to add a loading indicator here
return <div>Loading...</div> return <div>Loading...</div>
} }
@ -45,18 +44,16 @@ export function Providers({ children }: { children: React.ReactNode }) {
disableTransitionOnChange disableTransitionOnChange
enableColorScheme enableColorScheme
> >
<>
<Toaster /> <Toaster />
<WalletProvider> <WalletContextProvider>
<OctokitProviderWithRouter> <BackendProvider>
{gqlClient && (
<GQLClientProvider client={gqlClient}> <GQLClientProvider client={gqlClient}>
<OctokitProviderWithRouter>
{children} {children}
</GQLClientProvider>
)}
</OctokitProviderWithRouter> </OctokitProviderWithRouter>
</WalletProvider> </GQLClientProvider>
</> </BackendProvider>
</WalletContextProvider>
</ThemeProvider> </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 { Octokit, RequestError } from 'octokit'
import { useDebounceCallback } from 'usehooks-ts' import { useDebounceCallback } from 'usehooks-ts'
@ -55,21 +56,40 @@ export const OctokitProvider = ({
}) => { }) => {
const [authToken, setAuthToken] = useState<string | null>(null) const [authToken, setAuthToken] = useState<string | null>(null)
const [isAuth, setIsAuth] = useState(false) const [isAuth, setIsAuth] = useState(false)
// const navigate = externalNavigate || internalNavigateconst
const router = useRouter() const router = useRouter()
const { orgSlug } = useParams() const { orgSlug } = useParams()
// const { toast, dismiss } = useToast() const gqlClient = useGQLClient()
const client = useGQLClient()
/** /**
* @function fetchUser * @function fetchUser
* @description Fetches the user's GitHub token from the GQLClient. * @description Fetches the user's GitHub token from the GQLClient.
*/ */
const fetchUser = useCallback(async () => { 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
}
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) setAuthToken(user.gitHubToken)
}, [client]) } 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 * @function updateAuth
@ -82,25 +102,29 @@ export const OctokitProvider = ({
const octokit = useMemo(() => { const octokit = useMemo(() => {
if (!authToken) { if (!authToken) {
setIsAuth(false) setIsAuth(false)
console.log('🐙 Creating Octokit without auth token')
return new Octokit() return new Octokit()
} }
console.log('🐙 Creating Octokit with auth token')
setIsAuth(true) setIsAuth(true)
return new Octokit({ auth: authToken }) return new Octokit({ auth: authToken })
}, [authToken]) }, [authToken])
// Only fetch user when GQL client is available
useEffect(() => { useEffect(() => {
if (gqlClient) {
console.log('🔄 GQL client available, fetching user...')
fetchUser() fetchUser()
}, [fetchUser]) } else {
console.log('⏳ Waiting for GQL client...')
}
}, [gqlClient, fetchUser])
const debouncedUnauthorizedGithubHandler = useDebounceCallback( const debouncedUnauthorizedGithubHandler = useDebounceCallback(
useCallback( useCallback(
(error: RequestError) => { (error: RequestError) => {
toast.error(`GitHub authentication error: ${error.message}`, { toast.error(`GitHub authentication error: ${error.message}`)
// id: 'unauthorized-github-token',
// variant: 'error',
// onDismiss: dismiss
})
router.push(`/${orgSlug}/projects/create`) router.push(`/${orgSlug}/projects/create`)
}, },
@ -116,7 +140,9 @@ export const OctokitProvider = ({
error instanceof RequestError && error instanceof RequestError &&
error.status === UNAUTHORIZED_ERROR_CODE error.status === UNAUTHORIZED_ERROR_CODE
) { ) {
await client.unauthenticateGithub() if (gqlClient && typeof gqlClient.unauthenticateGithub === 'function') {
await gqlClient.unauthenticateGithub()
}
await fetchUser() await fetchUser()
debouncedUnauthorizedGithubHandler(error) debouncedUnauthorizedGithubHandler(error)
@ -131,7 +157,7 @@ export const OctokitProvider = ({
// Remove the interceptor when the component unmounts // Remove the interceptor when the component unmounts
octokit.hook.remove('request', interceptor) octokit.hook.remove('request', interceptor)
} }
}, [octokit, client, debouncedUnauthorizedGithubHandler, fetchUser]) }, [octokit, gqlClient, debouncedUnauthorizedGithubHandler, fetchUser])
return ( return (
<OctokitContext.Provider value={{ octokit, updateAuth, isAuth }}> <OctokitContext.Provider value={{ octokit, updateAuth, isAuth }}>

View File

@ -1,180 +1,43 @@
import type React from 'react' // src/context/WalletContext.tsx
import { 'use client'
type ReactNode,
createContext, import { createContext, useContext } from 'react'
useContext,
useEffect,
useState
} from 'react'
import { toast } from 'sonner'
/** /**
* @interface WalletContextType * Wallet Context Interface
* @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.
*/ */
interface WalletContextType { export interface WalletContextType {
// Wallet state
wallet: { wallet: {
id: string id: string
address?: string address: string
} | null } | 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> connect: () => Promise<void>
disconnect: () => void disconnect: () => void
checkSession: () => Promise<boolean>
// Debug info
lastError?: string
} }
/** /**
* @const WalletContext * Wallet Context
* @description Creates a context for managing wallet connection state.
*/ */
const WalletContext = createContext<WalletContextType | undefined>(undefined) export const WalletContext = createContext<WalletContextType | undefined>(undefined)
/** /**
* @component WalletProvider * useWallet Hook
* @description Provides the WalletContext to its children. * @description Hook to access wallet context. Must be used within WalletProvider.
* @param {Object} props - Component props * @throws Error if used outside WalletProvider
* @param {ReactNode} props.children - The children to render.
*/ */
export const WalletProvider: React.FC<{ children: ReactNode }> = ({ export const useWallet = (): WalletContextType => {
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 = () => {
const context = useContext(WalletContext) const context = useContext(WalletContext)
if (context === undefined) { if (context === undefined) {
throw new Error('useWallet must be used within a WalletProvider') throw new Error('useWallet must be used within a WalletProvider')

View File

@ -1,246 +1,255 @@
import { AutoSignInIFrameModal } from '@/components/iframe/auto-sign-in' // src/context/WalletProvider.tsx
import axios from 'axios' 'use client'
import { usePathname, useRouter } from 'next/navigation'
import type React from 'react' import type React from 'react'
import { import { type ReactNode, useState, useEffect, useCallback } from 'react'
type ReactNode,
createContext,
useContext,
useEffect,
useState
} from 'react'
import { SiweMessage, generateNonce } from 'siwe'
import { toast } from 'sonner' import { toast } from 'sonner'
import { WalletContext, type WalletContextType } from './WalletContext'
import { AutoSignInIFrameModal } from '@/components/iframe/auto-sign-in'
const axiosInstance = axios.create({ // Environment variables
baseURL: process.env.NEXT_PUBLIC_API_URL, const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
headers: { const WALLET_IFRAME_URL = process.env.NEXT_PUBLIC_WALLET_IFRAME_URL || 'http://localhost:4000'
'Content-Type': 'application/json', const WALLET_IFRAME_ID = 'wallet-communication-iframe'
'Access-Control-Allow-Origin': '*'
},
withCredentials: true
})
/** export const WalletContextProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
* @interface WalletContextType // Core wallet state
* @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
}) => {
const [wallet, setWallet] = useState<WalletContextType['wallet']>(null) const [wallet, setWallet] = useState<WalletContextType['wallet']>(null)
const [isConnected, setIsConnected] = useState(false) const [isConnected, setIsConnected] = useState(false)
const [isReady, setIsReady] = useState(false) const [hasWalletAddress, setHasWalletAddress] = useState(false)
const [accountAddress, setAccountAddress] = useState<string>() const [isLoading, setIsLoading] = useState(false)
const router = useRouter() const [lastError, setLastError] = useState<string>()
const pathname = usePathname()
const baseUrl = process.env.NEXT_PUBLIC_API_URL
// Update isReady state when connection changes // Modal state for SIWE authentication
useEffect(() => { const [showAuthModal, setShowAuthModal] = useState(false)
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 // Check if we have an active backend session
useEffect(() => { const checkSession = useCallback(async (): Promise<boolean> => {
fetch(`${baseUrl}/auth/session`, { try {
credentials: 'include' const response = await fetch(`${BACKEND_URL}/auth/session`, {
}).then((res) => { method: 'GET',
const path = pathname credentials: 'include',
console.log(res)
if (res.status !== 200) {
setIsConnected(false)
localStorage.clear()
if (path !== '/login') {
router.push('/login')
}
} else {
setIsConnected(true)
if (path === '/login') {
router.push('/')
}
}
}) })
}, [pathname, router, baseUrl])
// Handle wallet messages for account data const sessionExists = response.ok
setIsConnected(sessionExists)
if (sessionExists) {
console.log('✅ Active wallet session found')
} else {
console.log('❌ No active wallet session')
}
return sessionExists
} catch (error) {
console.error('Error checking wallet session:', error)
setLastError('Failed to check session')
setIsConnected(false)
return false
}
}, [])
// Initialize session check on mount
useEffect(() => {
checkSession()
}, [checkSession])
// Handle wallet messages from iframe
useEffect(() => { useEffect(() => {
const handleWalletMessage = (event: MessageEvent) => { const handleWalletMessage = (event: MessageEvent) => {
if (event.origin !== process.env.NEXT_PUBLIC_WALLET_IFRAME_URL) return // Security check
console.log(event) 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') { 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({ setWallet({
id: address, id: address,
address: address address: address
}) })
setAccountAddress(address) setHasWalletAddress(true)
setIsConnected(true) setLastError(undefined)
toast.success('Wallet Connected', {
// variant: 'success', // Check if we already have a session for this wallet
duration: 3000 checkSession().then(hasSession => {
// id: '', 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) window.addEventListener('message', handleWalletMessage)
return () => window.removeEventListener('message', handleWalletMessage) return () => window.removeEventListener('message', handleWalletMessage)
}, []) }, [checkSession])
// Handle sign-in response from the wallet iframe // Connect to wallet
useEffect(() => { const connect = useCallback(async () => {
const handleSignInResponse = async (event: MessageEvent) => { if (isLoading) {
if (event.origin !== process.env.NEXT_PUBLIC_WALLET_IFRAME_URL) return console.log('⏸️ Connection already in progress')
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)
}
}
}
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 return
} }
const message = new SiweMessage({ setIsLoading(true)
version: '1', setLastError(undefined)
domain: window.location.host,
uri: window.location.origin,
chainId: 1,
address: accountAddress,
nonce: generateNonce(),
statement: 'Sign in With Ethereum.'
}).prepareMessage()
iframe.contentWindow.postMessage( try {
{ console.log('🔌 Attempting to connect wallet...')
type: 'AUTO_SIGN_IN',
chainId: '1', // Find the wallet communication iframe
message const iframe = document.getElementById(WALLET_IFRAME_ID) as HTMLIFrameElement
},
process.env.NEXT_PUBLIC_WALLET_IFRAME_URL ?? '' if (!iframe) {
) throw new Error('Wallet communication interface not found')
} }
initiateAutoSignIn() if (!iframe.contentWindow) {
}, [accountAddress]) throw new Error('Wallet interface not loaded')
}
console.log('📤 Sending wallet connection request...')
const connect = async () => {
const iframe = document.getElementById('walletIframe') as HTMLIFrameElement
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage( iframe.contentWindow.postMessage(
{ {
type: 'REQUEST_WALLET_ACCOUNTS', 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', { // Set a timeout for connection attempt
// description: 'Wallet iframe not found or not loaded', const timeoutId = setTimeout(() => {
// variant: 'error', if (!hasWalletAddress) {
duration: 3000 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)
} }
const disconnect = () => { } 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) setWallet(null)
setIsConnected(false) setIsConnected(false)
toast.info('Wallet Disconnected', { setHasWalletAddress(false)
duration: 3000 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 ( return (
<WalletContext.Provider <WalletContext.Provider value={contextValue}>
value={{ wallet, isConnected, isReady, connect, disconnect }}
>
{children} {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> </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' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { useGQLClient } from '@/context' import { useGQLClient } from '@/context'
import { toast } from 'sonner' import { toast } from 'sonner'
interface DeploymentConfig { // Define the structure of deployment configuration
repositoryUrl: string; export interface DeploymentConfig {
branch: string; projectId?: string
environmentVariables?: Record<string, string>; organizationSlug: string
projectName?: string; repository: string
customDomain?: string; branch: string
name: string
environmentVariables?: Array<{
key: string
value: string
environments: string[]
}>
} }
interface DeploymentResult { // Define the structure of deployment result
id: string; export interface DeploymentResult {
url: string; id: string
status: 'pending' | 'building' | 'ready' | 'error'; url?: string
status: string
} }
export function useDeployment() { export function useDeployment() {
const [isDeploying, setIsDeploying] = useState(false) const [isDeploying, setIsDeploying] = useState(false)
const [deploymentResult, setDeploymentResult] = useState<DeploymentResult | null>(null) const [deploymentResult, setDeploymentResult] = useState<DeploymentResult | null>(null)
const [error, setError] = useState<string | null>(null)
const gqlClient = useGQLClient() const gqlClient = useGQLClient()
// Function to create a new project and deploy it
const deployRepository = async (config: DeploymentConfig): Promise<DeploymentResult> => { const deployRepository = async (config: DeploymentConfig): Promise<DeploymentResult> => {
setIsDeploying(true) setIsDeploying(true)
setError(null)
try { try {
// This is a placeholder query - you'll need to replace it with the actual GraphQL mutation console.log('🚀 Starting repository deployment:', config)
// 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
}
}
})
const deployment = result.data.createDeployment // Use the addProject mutation from your existing GraphQL client
setDeploymentResult(deployment) 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))
// 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') toast.success('Deployment initiated successfully')
return deployment return deploymentResult
} catch (error) { } catch (error) {
console.error('Deployment failed:', 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 throw error
} finally { } finally {
setIsDeploying(false) setIsDeploying(false)
} }
} }
const getDeploymentStatus = async (deploymentId: string): Promise<string> => { // Function to check the status of a deployment
const getDeploymentStatus = async (projectId: string) => {
try { try {
const result = await gqlClient.query({ const result = await gqlClient.getProject(projectId)
query: ` return result.project?.deployments?.[0]?.status
query GetDeploymentStatus($id: ID!) {
deployment(id: $id) {
id
status
}
}
`,
variables: {
id: deploymentId
}
})
return result.data.deployment.status
} catch (error) { } catch (error) {
console.error('Failed to get deployment status:', error) console.error('Failed to get deployment status:', error)
throw 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 { return {
deployRepository, deployRepository,
getDeploymentStatus, getDeploymentStatus,
getDeployments,
isDeploying, 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 // Create Octokit instance with whatever token we found
if (token) { if (token) {
setOctokit(new Octokit({ auth: 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", "packageManager": "pnpm@10.5.1",
"engines": { "engines": {
"node": ">=22.14.0" "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: 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: devDependencies:
'@biomejs/biome': '@biomejs/biome':
specifier: ^1.9.4 specifier: ^1.9.4
@ -112,7 +119,7 @@ importers:
version: 7.7.1 version: 7.7.1
siwe: siwe:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.0(ethers@5.8.0) version: 3.0.0(ethers@6.14.1)
toml: toml:
specifier: ^3.0.0 specifier: ^3.0.0
version: 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) version: 2.15.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
siwe: siwe:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.0.0(ethers@5.8.0) version: 3.0.0(ethers@6.14.1)
sonner: sonner:
specifier: ^2.0.1 specifier: ^2.0.1
version: 2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) 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 version: 1.9.4
'@biomejs/monorepo': '@biomejs/monorepo':
specifier: github:biomejs/biome 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': '@hookform/resolvers':
specifier: ^4.1.2 specifier: ^4.1.2
version: 4.1.3(react-hook-form@7.54.2(react@19.0.0)) version: 4.1.3(react-hook-form@7.54.2(react@19.0.0))
@ -575,6 +582,9 @@ importers:
packages: packages:
'@adraffy/ens-normalize@1.10.1':
resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==}
'@alloc/quick-lru@5.2.0': '@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -720,9 +730,10 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@biomejs/monorepo@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/7e51cd1e3139c46b4b45eab71936d1641230a566} resolution: {tarball: https://codeload.github.com/biomejs/biome/tar.gz/a82a1f2e50c5c9b22cc696960bc167631d1de455}
version: 0.0.0 version: 0.0.0
engines: {pnpm: 10.8.1}
'@cerc-io/laconic-registry-cli@0.2.10': '@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} 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] cpu: [x64]
os: [win32] os: [win32]
'@noble/curves@1.2.0':
resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==}
'@noble/curves@1.8.1': '@noble/curves@1.8.1':
resolution: {integrity: sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==} resolution: {integrity: sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==}
engines: {node: ^14.21.3 || >=16} 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': '@noble/hashes@1.7.1':
resolution: {integrity: sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==} resolution: {integrity: sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==}
engines: {node: ^14.21.3 || >=16} engines: {node: ^14.21.3 || >=16}
@ -2615,6 +2633,9 @@ packages:
'@types/node@22.13.9': '@types/node@22.13.9':
resolution: {integrity: sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==} resolution: {integrity: sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==}
'@types/node@22.7.5':
resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==}
'@types/pbkdf2@3.1.2': '@types/pbkdf2@3.1.2':
resolution: {integrity: sha512-uRwJqmiXmh9++aSu1VNEn3iIxWOhd8AHXNSdlaLfdAAdSTY9jYVeGWnzejM3dvrkbqE3/hyQkQQ29IFATEGlew==} resolution: {integrity: sha512-uRwJqmiXmh9++aSu1VNEn3iIxWOhd8AHXNSdlaLfdAAdSTY9jYVeGWnzejM3dvrkbqE3/hyQkQQ29IFATEGlew==}
@ -2691,6 +2712,9 @@ packages:
aes-js@3.0.0: aes-js@3.0.0:
resolution: {integrity: sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==} resolution: {integrity: sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==}
aes-js@4.0.0-beta.5:
resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==}
agent-base@7.1.3: agent-base@7.1.3:
resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
@ -3504,6 +3528,10 @@ packages:
ethers@5.8.0: ethers@5.8.0:
resolution: {integrity: sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg==} 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: ethjs-util@0.1.6:
resolution: {integrity: sha512-CUnVOQq7gSpDHZVVrQW8ExxUETWrnrvXYvYz55wOU8Uj4VCgw56XC2B/fVqQN+f7gmrnRHSLVnFAwsCuNwji8w==} resolution: {integrity: sha512-CUnVOQq7gSpDHZVVrQW8ExxUETWrnrvXYvYz55wOU8Uj4VCgw56XC2B/fVqQN+f7gmrnRHSLVnFAwsCuNwji8w==}
engines: {node: '>=6.5.0', npm: '>=3'} engines: {node: '>=6.5.0', npm: '>=3'}
@ -5259,6 +5287,9 @@ packages:
tslib@2.4.1: tslib@2.4.1:
resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==} resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==}
tslib@2.7.0:
resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==}
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@ -5592,6 +5623,18 @@ packages:
utf-8-validate: utf-8-validate:
optional: true 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: ws@8.18.0:
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
@ -5677,6 +5720,8 @@ packages:
snapshots: snapshots:
'@adraffy/ens-normalize@1.10.1': {}
'@alloc/quick-lru@5.2.0': {} '@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)': '@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': '@biomejs/cli-win32-x64@1.9.4':
optional: true 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': '@cerc-io/laconic-registry-cli@0.2.10':
dependencies: dependencies:
@ -6775,10 +6820,16 @@ snapshots:
'@next/swc-win32-x64-msvc@15.2.1': '@next/swc-win32-x64-msvc@15.2.1':
optional: true optional: true
'@noble/curves@1.2.0':
dependencies:
'@noble/hashes': 1.3.2
'@noble/curves@1.8.1': '@noble/curves@1.8.1':
dependencies: dependencies:
'@noble/hashes': 1.7.1 '@noble/hashes': 1.7.1
'@noble/hashes@1.3.2': {}
'@noble/hashes@1.7.1': {} '@noble/hashes@1.7.1': {}
'@nodelib/fs.scandir@2.1.5': '@nodelib/fs.scandir@2.1.5':
@ -8112,6 +8163,10 @@ snapshots:
dependencies: dependencies:
undici-types: 6.20.0 undici-types: 6.20.0
'@types/node@22.7.5':
dependencies:
undici-types: 6.19.8
'@types/pbkdf2@3.1.2': '@types/pbkdf2@3.1.2':
dependencies: dependencies:
'@types/node': 20.17.23 '@types/node': 20.17.23
@ -8189,6 +8244,8 @@ snapshots:
aes-js@3.0.0: {} aes-js@3.0.0: {}
aes-js@4.0.0-beta.5: {}
agent-base@7.1.3: {} agent-base@7.1.3: {}
aggregate-error@3.1.0: aggregate-error@3.1.0:
@ -9143,6 +9200,19 @@ snapshots:
- bufferutil - bufferutil
- utf-8-validate - 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: ethjs-util@0.1.6:
dependencies: dependencies:
is-hex-prefixed: 1.0.0 is-hex-prefixed: 1.0.0
@ -10727,11 +10797,11 @@ snapshots:
is-arrayish: 0.3.2 is-arrayish: 0.3.2
optional: true optional: true
siwe@3.0.0(ethers@5.8.0): siwe@3.0.0(ethers@6.14.1):
dependencies: dependencies:
'@spruceid/siwe-parser': 3.0.0 '@spruceid/siwe-parser': 3.0.0
'@stablelib/random': 1.0.2 '@stablelib/random': 1.0.2
ethers: 5.8.0 ethers: 6.14.1
slash@3.0.0: {} slash@3.0.0: {}
@ -11057,6 +11127,8 @@ snapshots:
tslib@2.4.1: {} tslib@2.4.1: {}
tslib@2.7.0: {}
tslib@2.8.1: {} tslib@2.8.1: {}
tsscmp@1.0.6: {} tsscmp@1.0.6: {}
@ -11324,6 +11396,8 @@ snapshots:
ws@7.5.10: {} ws@7.5.10: {}
ws@8.17.1: {}
ws@8.18.0: {} ws@8.18.0: {}
xss@1.0.15: xss@1.0.15: