Connected to backend and fixed up UI changes, created a test-connection page
This commit is contained in:
parent
16bb8acc7e
commit
4512ef1d8a
@ -3,14 +3,15 @@
|
||||
import { useParams } from 'next/navigation';
|
||||
import { PageWrapper } from '@/components/foundation';
|
||||
import { DeploymentDetailsCard } from '@/components/projects/project/deployments/DeploymentDetailsCard';
|
||||
import { FilterForm } from '@/components/projects/project/deployments/FilterForm';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
|
||||
import { IconButton } from '@workspace/ui/components/button';
|
||||
import { Rocket } from 'lucide-react';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import { Square, Search, Calendar, ChevronDown } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRepoData } from '@/hooks/useRepoData';
|
||||
import type { Deployment, Domain } from '@/types';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@workspace/ui/components/dialog';
|
||||
import { Input } from '@workspace/ui/components/input';
|
||||
|
||||
export default function DeploymentsPage() {
|
||||
const router = useRouter();
|
||||
@ -27,6 +28,11 @@ export default function DeploymentsPage() {
|
||||
const [filteredDeployments, setFilteredDeployments] = useState<Deployment[]>([]);
|
||||
const [prodBranchDomains, setProdBranchDomains] = useState<Domain[]>([]);
|
||||
|
||||
// State for deployment logs modal
|
||||
const [isLogsOpen, setIsLogsOpen] = useState(false);
|
||||
const [selectedDeploymentId, setSelectedDeploymentId] = useState<string | null>(null);
|
||||
const [deploymentLogs, setDeploymentLogs] = useState<string>('');
|
||||
|
||||
// Create a default deployment
|
||||
const defaultDeployment: Deployment = {
|
||||
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(() => {
|
||||
const mockDeployments = [defaultDeployment, secondDeployment];
|
||||
setDeployments(mockDeployments);
|
||||
setFilteredDeployments(mockDeployments);
|
||||
// For testing the empty state
|
||||
setDeployments([]);
|
||||
setFilteredDeployments([]);
|
||||
|
||||
// Uncomment to see mock deployments
|
||||
// const mockDeployments = [defaultDeployment, secondDeployment];
|
||||
// setDeployments(mockDeployments);
|
||||
// setFilteredDeployments(mockDeployments);
|
||||
|
||||
// Mock domains
|
||||
const mockDomains: Domain[] = [
|
||||
@ -102,6 +114,31 @@ export default function DeploymentsPage() {
|
||||
setFilteredDeployments(deployments);
|
||||
};
|
||||
|
||||
// View logs handler
|
||||
const handleViewLogs = (deploymentId: string) => {
|
||||
setSelectedDeploymentId(deploymentId);
|
||||
|
||||
// Mock logs data
|
||||
const mockLogs = `[2025-02-12 10:03:12] INFO Starting deployment process for service: api-gateway
|
||||
[2025-02-12 10:03:14] INFO Fetching latest commit from main branch (commit: a1b2c3d)
|
||||
[2025-02-12 10:03:15] INFO Building Docker image: registry.company.com/api-gateway:latest
|
||||
[2025-02-12 10:03:26] INFO Running security scan on built image
|
||||
[2025-02-12 10:03:27] WARNING Medium severity vulnerability detected in package 'openssl'
|
||||
[2025-02-12 10:03:30] INFO Pushing image to container registry
|
||||
[2025-02-12 10:03:35] INFO Updating Kubernetes deployment
|
||||
[2025-02-12 10:03:40] INFO Scaling down old pods
|
||||
[2025-02-12 10:03:42] INFO Scaling up new pods
|
||||
[2025-02-12 10:03:50] INFO Running health checks on new pods
|
||||
[2025-02-12 10:03:52] ERROR Pod 'api-gateway-7df9bbb500-tx2k4' failed readiness probe (502 Bad Gateway)
|
||||
[2025-02-12 10:03:55] INFO Retrying deployment with previous stable image
|
||||
[2025-02-12 10:04:03] INFO Rolling back to registry.company.com/api-gateway:previous
|
||||
[2025-02-12 10:04:10] INFO Deployment rolled back successfully
|
||||
[2025-02-12 10:04:11] ERROR Deployment failed, please review logs and fix errors`;
|
||||
|
||||
setDeploymentLogs(mockLogs);
|
||||
setIsLogsOpen(true);
|
||||
};
|
||||
|
||||
const project = {
|
||||
id: id,
|
||||
prodBranch: 'main',
|
||||
@ -110,6 +147,8 @@ export default function DeploymentsPage() {
|
||||
|
||||
const currentDeployment = deployments.find(deployment => deployment.isCurrent) || defaultDeployment;
|
||||
|
||||
const hasDeployments = deployments.length > 0;
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
@ -145,41 +184,108 @@ export default function DeploymentsPage() {
|
||||
</Tabs>
|
||||
|
||||
<div className="mt-6">
|
||||
<FilterForm />
|
||||
{/* Filter Controls - Always visible but disabled when no deployments */}
|
||||
<div className="flex flex-wrap gap-4 mb-4">
|
||||
{/* Search box */}
|
||||
<div className="relative flex-grow max-w-md">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search branches"
|
||||
className="pl-10"
|
||||
disabled={!hasDeployments}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date selector */}
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center opacity-60"
|
||||
disabled={!hasDeployments}
|
||||
>
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
<span>Select a date</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Status dropdown */}
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center opacity-60"
|
||||
disabled={!hasDeployments}
|
||||
>
|
||||
<span>All Status</span>
|
||||
<ChevronDown className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-full mt-4">
|
||||
{filteredDeployments.length > 0 ? (
|
||||
filteredDeployments.map((deployment) => (
|
||||
<DeploymentDetailsCard
|
||||
key={deployment.id}
|
||||
deployment={deployment}
|
||||
currentDeployment={currentDeployment}
|
||||
project={project}
|
||||
prodBranchDomains={prodBranchDomains}
|
||||
/>
|
||||
<div key={deployment.id} className="mb-4">
|
||||
<DeploymentDetailsCard
|
||||
deployment={deployment}
|
||||
currentDeployment={currentDeployment}
|
||||
project={project}
|
||||
prodBranchDomains={prodBranchDomains}
|
||||
/>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleViewLogs(deployment.id)}
|
||||
>
|
||||
View logs
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="h-96 bg-base-bg-alternate dark:bg-overlay3 rounded-xl flex flex-col items-center justify-center gap-5 text-center">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium tracking-[-0.011em] text-elements-high-em dark:text-foreground">
|
||||
No deployments found
|
||||
</p>
|
||||
<p className="text-sm tracking-[-0.006em] text-elements-mid-em dark:text-foreground-secondary">
|
||||
Please change your search query or filters.
|
||||
</p>
|
||||
// Updated empty state to match screenshot
|
||||
<div className="h-96 border border-gray-800 rounded-lg flex flex-col items-center justify-center gap-5 text-center">
|
||||
<div className="mb-6">
|
||||
<Square size={64} className="stroke-current" />
|
||||
</div>
|
||||
<IconButton
|
||||
<h2 className="text-xl font-semibold mb-2">You have no deployments</h2>
|
||||
<p className="text-gray-400 text-center max-w-md mb-6">
|
||||
Please change your search query or filters.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Rocket className="w-4 h-4" />}
|
||||
onClick={handleResetFilters}
|
||||
>
|
||||
RESET FILTERS
|
||||
</IconButton>
|
||||
Reset filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deployment Logs Modal */}
|
||||
<Dialog open={isLogsOpen} onOpenChange={setIsLogsOpen}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Deployment Logs</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-auto mt-4">
|
||||
<pre className="bg-black text-green-400 p-4 rounded text-sm font-mono whitespace-pre overflow-x-auto">
|
||||
{deploymentLogs}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button variant="secondary" onClick={() => setIsLogsOpen(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { LoadingOverlay } from "@/components/foundation/loading/loading-overlay";
|
||||
|
||||
interface SwitchProps {
|
||||
@ -26,12 +25,12 @@ function Switch({ id, checked, onChange, disabled = false }: SwitchProps) {
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div
|
||||
className={`relative w-11 h-6 bg-gray-800 rounded-full transition-colors
|
||||
${checked ? 'bg-blue-600' : 'bg-gray-700'}`}
|
||||
className={`relative w-11 h-6 bg-muted rounded-full transition-colors
|
||||
${checked ? 'bg-primary' : 'bg-muted'}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute w-4 h-4 bg-white rounded-full transition-transform transform
|
||||
${checked ? 'translate-x-6' : 'translate-x-1'} top-1`}
|
||||
className={`absolute w-4 h-4 bg-background rounded-full transition-transform transform
|
||||
${checked ? 'translate-x-6' : 'translate-x-1'} top-1 border border-border`}
|
||||
></div>
|
||||
</div>
|
||||
</label>
|
||||
@ -39,9 +38,6 @@ function Switch({ id, checked, onChange, disabled = false }: SwitchProps) {
|
||||
}
|
||||
|
||||
export default function GitPage() {
|
||||
const params = useParams();
|
||||
const { provider, id } = params;
|
||||
|
||||
const [pullRequestComments, setPullRequestComments] = useState(true);
|
||||
const [commitComments, setCommitComments] = useState(false);
|
||||
const [productionBranch, setProductionBranch] = useState("main");
|
||||
@ -88,8 +84,8 @@ export default function GitPage() {
|
||||
{(isSavingBranch || isSavingWebhook) && <LoadingOverlay />}
|
||||
|
||||
<div className="space-y-8 w-full">
|
||||
<div className="rounded-lg border border-gray-800 p-6 bg-black">
|
||||
<h2 className="text-xl font-semibold mb-4">Git repository</h2>
|
||||
<div className="rounded-lg border border-border p-6 bg-card">
|
||||
<h2 className="text-xl font-semibold mb-4 text-foreground">Git repository</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
@ -100,11 +96,11 @@ export default function GitPage() {
|
||||
checked={pullRequestComments}
|
||||
onChange={setPullRequestComments}
|
||||
/>
|
||||
<label htmlFor="pull-request-comments" className="text-sm font-medium">
|
||||
<label htmlFor="pull-request-comments" className="text-sm font-medium text-foreground">
|
||||
Pull request comments
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mt-1 ml-14">
|
||||
<p className="text-sm text-muted-foreground mt-1 ml-14">
|
||||
Laconic will comment on pull requests opened against this project.
|
||||
</p>
|
||||
</div>
|
||||
@ -118,11 +114,11 @@ export default function GitPage() {
|
||||
checked={commitComments}
|
||||
onChange={setCommitComments}
|
||||
/>
|
||||
<label htmlFor="commit-comments" className="text-sm font-medium">
|
||||
<label htmlFor="commit-comments" className="text-sm font-medium text-foreground">
|
||||
Commit comments
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mt-1 ml-14">
|
||||
<p className="text-sm text-muted-foreground mt-1 ml-14">
|
||||
Laconic will comment on commits deployed to production.
|
||||
</p>
|
||||
</div>
|
||||
@ -130,47 +126,47 @@ export default function GitPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-800 p-6 bg-black">
|
||||
<h2 className="text-xl font-semibold mb-4">Production branch</h2>
|
||||
<div className="rounded-lg border border-border p-6 bg-card">
|
||||
<h2 className="text-xl font-semibold mb-4 text-foreground">Production branch</h2>
|
||||
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
By default, each commit pushed to the main branch initiates a production deployment. You can opt for a
|
||||
different branch for deployment in the settings.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="branch-name" className="block text-sm font-medium mb-1">
|
||||
<label htmlFor="branch-name" className="block text-sm font-medium mb-1 text-foreground">
|
||||
Branch name
|
||||
</label>
|
||||
<input
|
||||
id="branch-name"
|
||||
value={productionBranch}
|
||||
onChange={(e) => setProductionBranch(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
|
||||
className="w-full px-3 py-2 rounded-md bg-background border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="px-4 py-2 border border-gray-600 rounded-md hover:bg-gray-800 transition-colors"
|
||||
className="px-4 py-2 border border-border rounded-md hover:bg-accent hover:text-accent-foreground transition-colors bg-background text-foreground"
|
||||
onClick={handleSaveBranch}
|
||||
disabled={isSavingBranch}
|
||||
>
|
||||
Save
|
||||
{isSavingBranch ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-800 p-6 bg-black">
|
||||
<h2 className="text-xl font-semibold mb-4">Deploy webhooks</h2>
|
||||
<div className="rounded-lg border border-border p-6 bg-card">
|
||||
<h2 className="text-xl font-semibold mb-4 text-foreground">Deploy webhooks</h2>
|
||||
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Webhooks configured to trigger when there is a change in a project's build or deployment status.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="webhook-url" className="block text-sm font-medium mb-1">
|
||||
<label htmlFor="webhook-url" className="block text-sm font-medium mb-1 text-foreground">
|
||||
Webhook URL
|
||||
</label>
|
||||
<div className="flex">
|
||||
@ -179,14 +175,14 @@ export default function GitPage() {
|
||||
value={webhookUrl}
|
||||
onChange={(e) => setWebhookUrl(e.target.value)}
|
||||
placeholder="https://"
|
||||
className="flex-1 px-3 py-2 rounded-l-md bg-gray-900 border border-gray-700 text-white"
|
||||
className="flex-1 px-3 py-2 rounded-l-md bg-background border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<button
|
||||
className="px-4 py-2 border border-gray-600 border-l-0 rounded-r-md hover:bg-gray-800 transition-colors"
|
||||
className="px-4 py-2 border border-border border-l-0 rounded-r-md hover:bg-accent hover:text-accent-foreground transition-colors bg-background text-foreground"
|
||||
onClick={handleSaveWebhook}
|
||||
disabled={isSavingWebhook}
|
||||
>
|
||||
Save
|
||||
{isSavingWebhook ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { LoadingOverlay } from "@/components/foundation/loading/loading-overlay";
|
||||
import { PlusIcon, ChevronDownIcon, ChevronUpIcon, PencilIcon, TrashIcon } from "lucide-react";
|
||||
|
||||
@ -21,16 +20,16 @@ interface EnvGroupProps {
|
||||
|
||||
function EnvGroup({ title, isOpen, onToggle, children, varCount }: EnvGroupProps) {
|
||||
return (
|
||||
<div className="border-b border-gray-800 last:border-b-0">
|
||||
<div className="border-b border-border last:border-b-0">
|
||||
<div
|
||||
className="flex items-center justify-between py-4 cursor-pointer"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="text-lg font-medium">{title}</h3>
|
||||
<span className="text-sm text-gray-400">({varCount})</span>
|
||||
<h3 className="text-lg font-medium text-foreground">{title}</h3>
|
||||
<span className="text-sm text-muted-foreground">({varCount})</span>
|
||||
</div>
|
||||
<button className="p-1">
|
||||
<button className="p-1 text-foreground hover:text-accent-foreground">
|
||||
{isOpen ? <ChevronUpIcon size={18} /> : <ChevronDownIcon size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
@ -44,9 +43,6 @@ function EnvGroup({ title, isOpen, onToggle, children, varCount }: EnvGroupProps
|
||||
}
|
||||
|
||||
export default function EnvVarsPage() {
|
||||
const params = useParams();
|
||||
const { provider, id } = params;
|
||||
|
||||
const [isAddingVar, setIsAddingVar] = useState(false);
|
||||
const [newVarKey, setNewVarKey] = useState("");
|
||||
const [newVarValue, setNewVarValue] = useState("");
|
||||
@ -175,7 +171,7 @@ export default function EnvVarsPage() {
|
||||
return (
|
||||
<div key={index} className="flex items-center space-x-2 mb-2">
|
||||
<input
|
||||
className="flex-1 px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
|
||||
className="flex-1 px-3 py-2 rounded-md bg-background border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={variable.key}
|
||||
onChange={(e) => {
|
||||
const updatedVars = env === 'production'
|
||||
@ -191,7 +187,7 @@ export default function EnvVarsPage() {
|
||||
placeholder="KEY"
|
||||
/>
|
||||
<input
|
||||
className="flex-1 px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
|
||||
className="flex-1 px-3 py-2 rounded-md bg-background border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={variable.value}
|
||||
onChange={(e) => {
|
||||
const updatedVars = env === 'production'
|
||||
@ -207,13 +203,13 @@ export default function EnvVarsPage() {
|
||||
placeholder="Value"
|
||||
/>
|
||||
<button
|
||||
className="p-2 hover:bg-gray-800 rounded-md"
|
||||
className="p-2 hover:bg-accent hover:text-accent-foreground rounded-md text-foreground transition-colors"
|
||||
onClick={() => updateVariable(env, index, variable.key, variable.value)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
className="p-2 hover:bg-gray-800 rounded-md"
|
||||
className="p-2 hover:bg-accent hover:text-accent-foreground rounded-md text-foreground transition-colors"
|
||||
onClick={() => {
|
||||
const updatedVars = env === 'production'
|
||||
? [...productionVars]
|
||||
@ -234,19 +230,19 @@ export default function EnvVarsPage() {
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center mb-2">
|
||||
<div className="flex-1 flex items-center justify-between px-3 py-2 rounded-md bg-gray-900 border border-gray-700 mr-2">
|
||||
<span>{variable.key}</span>
|
||||
<span>{variable.value}</span>
|
||||
<div className="flex-1 flex items-center justify-between px-3 py-2 rounded-md bg-muted border border-border mr-2">
|
||||
<span className="text-foreground">{variable.key}</span>
|
||||
<span className="text-foreground">{variable.value}</span>
|
||||
</div>
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
className="p-2 hover:bg-gray-800 rounded-md"
|
||||
className="p-2 hover:bg-accent hover:text-accent-foreground rounded-md text-muted-foreground transition-colors"
|
||||
onClick={() => editVariable(env, index)}
|
||||
>
|
||||
<PencilIcon size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="p-2 hover:bg-gray-800 rounded-md"
|
||||
className="p-2 hover:bg-accent hover:text-accent-foreground rounded-md text-muted-foreground transition-colors"
|
||||
onClick={() => removeVariable(env, index)}
|
||||
>
|
||||
<TrashIcon size={16} />
|
||||
@ -261,45 +257,45 @@ export default function EnvVarsPage() {
|
||||
{isSaving && <LoadingOverlay />}
|
||||
|
||||
<div className="space-y-6 w-full">
|
||||
<div className="rounded-lg border border-gray-800 p-6 bg-black">
|
||||
<h2 className="text-xl font-semibold mb-4">Environment Variables</h2>
|
||||
<p className="text-sm text-gray-400 mb-6">
|
||||
<div className="rounded-lg border border-border p-6 bg-card">
|
||||
<h2 className="text-xl font-semibold mb-4 text-foreground">Environment Variables</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
A new deployment is required for your changes to take effect.
|
||||
</p>
|
||||
|
||||
{!isAddingVar ? (
|
||||
<button
|
||||
className="flex items-center space-x-2 px-4 py-2 rounded-md border border-gray-700 bg-gray-900 hover:bg-gray-800 transition-colors"
|
||||
className="flex items-center space-x-2 px-4 py-2 rounded-md border border-border bg-background hover:bg-accent hover:text-accent-foreground transition-colors text-foreground"
|
||||
onClick={() => setIsAddingVar(true)}
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
<span>Create new variable</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-4 mb-6 border border-gray-800 p-4 rounded-md">
|
||||
<div className="space-y-4 mb-6 border border-border p-4 rounded-md bg-background">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Key</label>
|
||||
<label className="block text-sm font-medium mb-1 text-foreground">Key</label>
|
||||
<input
|
||||
value={newVarKey}
|
||||
onChange={(e) => setNewVarKey(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
|
||||
className="w-full px-3 py-2 rounded-md bg-background border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="KEY"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Value</label>
|
||||
<label className="block text-sm font-medium mb-1 text-foreground">Value</label>
|
||||
<input
|
||||
value={newVarValue}
|
||||
onChange={(e) => setNewVarValue(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
|
||||
className="w-full px-3 py-2 rounded-md bg-background border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Environments</label>
|
||||
<label className="block text-sm font-medium mb-2 text-foreground">Environments</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
@ -307,9 +303,9 @@ export default function EnvVarsPage() {
|
||||
id="env-production"
|
||||
checked={envSelection.production}
|
||||
onChange={() => handleEnvSelectionChange('production')}
|
||||
className="mr-2"
|
||||
className="mr-2 rounded border-border"
|
||||
/>
|
||||
<label htmlFor="env-production">Production</label>
|
||||
<label htmlFor="env-production" className="text-foreground">Production</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
@ -317,9 +313,9 @@ export default function EnvVarsPage() {
|
||||
id="env-preview"
|
||||
checked={envSelection.preview}
|
||||
onChange={() => handleEnvSelectionChange('preview')}
|
||||
className="mr-2"
|
||||
className="mr-2 rounded border-border"
|
||||
/>
|
||||
<label htmlFor="env-preview">Preview</label>
|
||||
<label htmlFor="env-preview" className="text-foreground">Preview</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
@ -327,22 +323,22 @@ export default function EnvVarsPage() {
|
||||
id="env-development"
|
||||
checked={envSelection.development}
|
||||
onChange={() => handleEnvSelectionChange('development')}
|
||||
className="mr-2"
|
||||
className="mr-2 rounded border-border"
|
||||
/>
|
||||
<label htmlFor="env-development">Development</label>
|
||||
<label htmlFor="env-development" className="text-foreground">Development</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
className="px-4 py-2 rounded-md border border-gray-700 hover:bg-gray-800 transition-colors"
|
||||
className="px-4 py-2 rounded-md border border-border hover:bg-accent hover:text-accent-foreground transition-colors bg-background text-foreground"
|
||||
onClick={cancelAddVariable}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 rounded-md bg-blue-600 hover:bg-blue-700 transition-colors"
|
||||
className="px-4 py-2 rounded-md bg-primary hover:bg-primary/90 transition-colors text-primary-foreground"
|
||||
onClick={addVariable}
|
||||
disabled={!newVarKey.trim() || !newVarValue.trim()}
|
||||
>
|
||||
@ -364,7 +360,7 @@ export default function EnvVarsPage() {
|
||||
{productionVars.map((variable, index) => renderEnvVarRow('production', variable, index))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">No variables defined</p>
|
||||
<p className="text-sm text-muted-foreground">No variables defined</p>
|
||||
)}
|
||||
</EnvGroup>
|
||||
|
||||
@ -379,7 +375,7 @@ export default function EnvVarsPage() {
|
||||
{previewVars.map((variable, index) => renderEnvVarRow('preview', variable, index))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">No variables defined</p>
|
||||
<p className="text-sm text-muted-foreground">No variables defined</p>
|
||||
)}
|
||||
</EnvGroup>
|
||||
|
||||
@ -394,18 +390,18 @@ export default function EnvVarsPage() {
|
||||
{deploymentVars.map((variable, index) => renderEnvVarRow('development', variable, index))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400">No variables defined</p>
|
||||
<p className="text-sm text-muted-foreground">No variables defined</p>
|
||||
)}
|
||||
</EnvGroup>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button
|
||||
className="px-4 py-2 rounded-md bg-gray-800 hover:bg-gray-700 transition-colors"
|
||||
className="px-4 py-2 rounded-md bg-accent hover:bg-accent/90 transition-colors text-accent-foreground"
|
||||
onClick={saveChanges}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Save changes
|
||||
{isSaving ? "Saving..." : "Save changes"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,47 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { Clipboard } from "lucide-react";
|
||||
import { Dropdown } from "@/components/core/dropdown";
|
||||
import { LoadingOverlay } from "@/components/foundation/loading/loading-overlay";
|
||||
import { useRepoData } from "@/hooks/useRepoData";
|
||||
import { useGQLClient } from "@/context";
|
||||
import type { Project } from '@workspace/gql-client';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import { Input } from '@workspace/ui/components/input';
|
||||
import { Label } from '@workspace/ui/components/label';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@workspace/ui/components/dialog';
|
||||
|
||||
// Create a simple modal component
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
interface ProjectSettingsPageProps {
|
||||
project?: Project;
|
||||
onProjectUpdated?: () => void;
|
||||
}
|
||||
|
||||
function Modal({ isOpen, onClose, title, children, footer }: ModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-lg w-full max-w-md p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-6">{children}</div>
|
||||
{footer && <div className="flex justify-end">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProjectSettingsPage() {
|
||||
export default function ProjectSettingsPage({ project, onProjectUpdated }: ProjectSettingsPageProps) {
|
||||
const client = useGQLClient();
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const id = params?.id ? String(params.id) : '';
|
||||
|
||||
// Use the hook to get repo data
|
||||
const { repoData, isLoading } = useRepoData(id);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [projectDescription, setProjectDescription] = useState("");
|
||||
@ -51,70 +30,111 @@ export default function ProjectSettingsPage() {
|
||||
const [isTransferring, setIsTransferring] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
// Update form values when project data is loaded
|
||||
useEffect(() => {
|
||||
if (repoData) {
|
||||
setProjectName(repoData.name || "");
|
||||
setProjectDescription(repoData.description || "");
|
||||
setProjectId(repoData.id?.toString() || "");
|
||||
if (project) {
|
||||
setProjectName(project.name || "");
|
||||
setProjectDescription(project.description || "");
|
||||
setProjectId(project.id || "");
|
||||
}
|
||||
}, [repoData]);
|
||||
}, [project]);
|
||||
|
||||
// Check for delete action in URL params
|
||||
useEffect(() => {
|
||||
const action = searchParams?.get('action');
|
||||
if (action === 'delete' && project) {
|
||||
setIsDeleteModalOpen(true);
|
||||
// Clean up the URL by removing the query parameter
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('action');
|
||||
router.replace(url.pathname);
|
||||
}
|
||||
}, [searchParams, project, router]);
|
||||
|
||||
const accountOptions = [
|
||||
{ label: "Personal Account", value: "account1" },
|
||||
{ label: "Team Account", value: "account2" }
|
||||
];
|
||||
|
||||
const showMessage = (message: string, isError = false) => {
|
||||
if (isError) {
|
||||
setErrorMessage(message);
|
||||
setSuccessMessage("");
|
||||
} else {
|
||||
setSuccessMessage(message);
|
||||
setErrorMessage("");
|
||||
}
|
||||
setTimeout(() => {
|
||||
setSuccessMessage("");
|
||||
setErrorMessage("");
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!project) return;
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
console.log("Saving project info:", { projectName, projectDescription });
|
||||
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await client.updateProject(project.id, {
|
||||
name: projectName,
|
||||
description: projectDescription
|
||||
});
|
||||
|
||||
// Show success notification - in a real app you'd use a toast library
|
||||
console.log("Project updated successfully");
|
||||
showMessage("Project updated successfully");
|
||||
if (onProjectUpdated) {
|
||||
onProjectUpdated();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save project info:", error);
|
||||
// Show error notification
|
||||
showMessage("Failed to update project", true);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTransfer = async () => {
|
||||
if (!project) return;
|
||||
|
||||
try {
|
||||
setIsTransferring(true);
|
||||
// Transfer project to selected account
|
||||
|
||||
// TODO: Implement actual transfer API call when available
|
||||
console.log("Transferring project to:", selectedAccount);
|
||||
// Implement API call to transfer project
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call
|
||||
|
||||
// After successful transfer, navigate back to projects list
|
||||
router.push("/dashboard/projects");
|
||||
showMessage("Project transfer initiated successfully");
|
||||
// Note: No navigation - staying in the same tab
|
||||
} catch (error) {
|
||||
console.error("Failed to transfer project:", error);
|
||||
// Show error notification
|
||||
showMessage("Failed to transfer project", true);
|
||||
} finally {
|
||||
setIsTransferring(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!project) return;
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
// Delete project
|
||||
console.log("Deleting project");
|
||||
// Implement API call to delete project
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Simulate API call
|
||||
|
||||
// After successful deletion, navigate back to projects list
|
||||
router.push("/dashboard/projects");
|
||||
await client.deleteProject(project.id);
|
||||
|
||||
showMessage("Project deleted successfully");
|
||||
setIsDeleteModalOpen(false);
|
||||
|
||||
// Navigate back to projects list after successful deletion
|
||||
setTimeout(() => {
|
||||
router.push('/projects');
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to delete project:", error);
|
||||
// Show error notification
|
||||
showMessage("Failed to delete project", true);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsDeleteModalOpen(false);
|
||||
@ -123,29 +143,15 @@ export default function ProjectSettingsPage() {
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
showMessage("Project ID copied to clipboard");
|
||||
};
|
||||
|
||||
const DeleteModalFooter = (
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
className="px-4 py-2 bg-gray-800 hover:bg-gray-700 rounded-md text-white"
|
||||
onClick={() => setIsDeleteModalOpen(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-md text-white"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingOverlay />;
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="p-6 text-center text-muted-foreground">
|
||||
No project data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@ -153,47 +159,61 @@ export default function ProjectSettingsPage() {
|
||||
{(isSaving || isTransferring || isDeleting) && <LoadingOverlay />}
|
||||
|
||||
<div className="space-y-8 w-full">
|
||||
<div className="rounded-lg border border-gray-800 p-6 bg-black">
|
||||
<h2 className="text-xl font-semibold mb-4">Project Info</h2>
|
||||
{/* Success/Error Messages */}
|
||||
{successMessage && (
|
||||
<div className="bg-green-500/10 text-green-500 p-3 rounded-md border border-green-500/20">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<div className="bg-destructive/10 text-destructive p-3 rounded-md border border-destructive/20">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project Info Section */}
|
||||
<div className="rounded-lg border border-border p-6 bg-card">
|
||||
<h2 className="text-xl font-semibold mb-4 text-card-foreground">Project Info</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="appName" className="block text-sm font-medium mb-1">
|
||||
<Label htmlFor="appName" className="text-card-foreground">
|
||||
App name
|
||||
</label>
|
||||
<input
|
||||
</Label>
|
||||
<Input
|
||||
id="appName"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium mb-1">
|
||||
<Label htmlFor="description" className="text-card-foreground">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={projectDescription}
|
||||
onChange={(e) => setProjectDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white"
|
||||
placeholder="Enter project description"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="projectId" className="block text-sm font-medium mb-1">
|
||||
<Label htmlFor="projectId" className="text-card-foreground">
|
||||
Project ID
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
</Label>
|
||||
<div className="relative mt-1">
|
||||
<Input
|
||||
id="projectId"
|
||||
value={projectId}
|
||||
readOnly
|
||||
className="w-full px-3 py-2 rounded-md bg-gray-900 border border-gray-700 text-white pr-10"
|
||||
className="pr-10 bg-muted"
|
||||
/>
|
||||
<button
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2"
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 hover:text-foreground text-muted-foreground transition-colors"
|
||||
onClick={() => copyToClipboard(projectId)}
|
||||
aria-label="Copy project ID"
|
||||
>
|
||||
@ -202,73 +222,132 @@ export default function ProjectSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="px-4 py-2 border border-gray-600 rounded-md hover:bg-gray-800 transition-colors mt-2"
|
||||
<div>
|
||||
<Label className="text-card-foreground">Repository</Label>
|
||||
<Input
|
||||
value={project.repository || 'No repository linked'}
|
||||
readOnly
|
||||
className="mt-1 bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-card-foreground">Framework</Label>
|
||||
<Input
|
||||
value={project.framework || 'Unknown'}
|
||||
readOnly
|
||||
className="mt-1 bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-card-foreground">Production Branch</Label>
|
||||
<Input
|
||||
value={project.prodBranch || 'main'}
|
||||
readOnly
|
||||
className="mt-1 bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-card-foreground">Organization</Label>
|
||||
<Input
|
||||
value={project.organization?.name || 'Unknown'}
|
||||
readOnly
|
||||
className="mt-1 bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="mt-4"
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-800 p-6 bg-black">
|
||||
<h2 className="text-xl font-semibold mb-4">Transfer Project</h2>
|
||||
{/* Transfer Project Section */}
|
||||
<div className="rounded-lg border border-border p-6 bg-card">
|
||||
<h2 className="text-xl font-semibold mb-4 text-card-foreground">Transfer Project</h2>
|
||||
|
||||
<div>
|
||||
<label htmlFor="account" className="block text-sm font-medium mb-1">
|
||||
Select account
|
||||
</label>
|
||||
<Dropdown
|
||||
label="Select"
|
||||
options={accountOptions}
|
||||
selectedValue={selectedAccount}
|
||||
onSelect={(value) => setSelectedAccount(value)}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="account" className="text-card-foreground">
|
||||
Select account
|
||||
</Label>
|
||||
<Dropdown
|
||||
label="Select"
|
||||
options={accountOptions}
|
||||
selectedValue={selectedAccount}
|
||||
onSelect={(value) => setSelectedAccount(value)}
|
||||
className="w-full mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-400 mt-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Transfer this app to your personal account or a team you are a member of.
|
||||
</p>
|
||||
|
||||
<button
|
||||
className="px-4 py-2 border border-gray-600 rounded-md hover:bg-gray-800 transition-colors mt-4"
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTransfer}
|
||||
disabled={!selectedAccount || isTransferring}
|
||||
>
|
||||
{isTransferring ? "Transferring..." : "Transfer"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-800 border-red-900 p-6 bg-black">
|
||||
<h2 className="text-xl font-semibold mb-4 text-red-500">Delete Project</h2>
|
||||
{/* Delete Project Section */}
|
||||
<div className="rounded-lg border border-destructive/50 p-6 bg-card">
|
||||
<h2 className="text-xl font-semibold mb-4 text-destructive">Delete Project</h2>
|
||||
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The project will be permanently deleted, including its deployments and domains. This action is
|
||||
irreversible and cannot be undone.
|
||||
</p>
|
||||
|
||||
<button
|
||||
className="px-4 py-2 bg-red-700 hover:bg-red-800 rounded-md text-white transition-colors"
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
>
|
||||
Delete project
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => !isDeleting && setIsDeleteModalOpen(false)}
|
||||
title="Are you absolutely sure?"
|
||||
footer={DeleteModalFooter}
|
||||
>
|
||||
<p className="text-gray-300">
|
||||
This action cannot be undone. This will permanently delete the project
|
||||
and all associated deployments and domains.
|
||||
</p>
|
||||
</Modal>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Dialog open={isDeleteModalOpen} onOpenChange={(open) => !isDeleting && setIsDeleteModalOpen(open)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-foreground">
|
||||
This action cannot be undone. This will permanently delete the project{" "}
|
||||
<strong>"{project.name}"</strong> and all associated deployments and domains.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsDeleteModalOpen(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,21 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PageWrapper } from "@/components/foundation";
|
||||
import { Tabs, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
|
||||
import ProjectSettingsPage from "./ProjectSettingsPage";
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRepoData } from '@/hooks/useRepoData';
|
||||
import { useGQLClient } from '@/context';
|
||||
import type { Project } from '@workspace/gql-client';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const client = useGQLClient();
|
||||
|
||||
// Safely unwrap params
|
||||
const id = params?.id ? String(params.id) : '';
|
||||
const provider = params?.provider ? String(params.provider) : '';
|
||||
|
||||
// Use the hook to get repo data
|
||||
const { repoData } = useRepoData(id);
|
||||
// State for project data
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load project data using GraphQL client
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
loadProject(id);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const loadProject = async (projectId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await client.getProject(projectId);
|
||||
setProject(response.project);
|
||||
} catch (err) {
|
||||
console.error('Failed to load project:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load project');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle project updates
|
||||
const handleProjectUpdated = () => {
|
||||
if (id) {
|
||||
loadProject(id);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle tab changes by navigating to the correct folder
|
||||
const handleTabChange = (value: string) => {
|
||||
@ -40,20 +75,59 @@ export default function SettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: 'Loading...',
|
||||
actions: []
|
||||
}}
|
||||
layout="bento"
|
||||
className="pb-0"
|
||||
>
|
||||
<div className="md:col-span-3 w-full flex items-center justify-center py-12">
|
||||
<div className="text-muted-foreground">Loading project settings...</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (error || !project) {
|
||||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: 'Error',
|
||||
actions: []
|
||||
}}
|
||||
layout="bento"
|
||||
className="pb-0"
|
||||
>
|
||||
<div className="md:col-span-3 w-full flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<div className="text-destructive mb-2">Failed to load project</div>
|
||||
<div className="text-muted-foreground text-sm">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: repoData ? `${repoData.name}` : 'Project Settings',
|
||||
title: project.name || 'Project Settings',
|
||||
actions: [
|
||||
{
|
||||
label: 'Open repo',
|
||||
href: repoData?.html_url || '#',
|
||||
href: project.repository || '#',
|
||||
icon: 'external-link',
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: 'View app',
|
||||
href: repoData ? `https://${repoData.name.toLowerCase()}.example.com` : '#',
|
||||
href: project.deployments?.[0]?.applicationDeploymentRecordData?.url || '#',
|
||||
icon: 'external-link',
|
||||
external: true
|
||||
}
|
||||
@ -74,9 +148,12 @@ export default function SettingsPage() {
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* Settings content */}
|
||||
{/* Settings content - now with proper project data */}
|
||||
<div className="mt-6">
|
||||
<ProjectSettingsPage />
|
||||
<ProjectSettingsPage
|
||||
project={project}
|
||||
onProjectUpdated={handleProjectUpdated}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
|
@ -9,119 +9,325 @@ import {
|
||||
AvatarFallback} from '@workspace/ui/components/avatar';
|
||||
import { Button } from '@workspace/ui/components/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@workspace/ui/components/tabs';
|
||||
import { Activity, Clock, GitBranch, ExternalLink } from 'lucide-react';
|
||||
import { Activity, Clock, GitBranch, ExternalLink, AlertCircle, Square, Search, Calendar, ChevronDown, Clipboard } from 'lucide-react';
|
||||
import { Badge } from '@workspace/ui/components/badge';
|
||||
import { Input } from '@workspace/ui/components/input';
|
||||
import { Label } from '@workspace/ui/components/label';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@workspace/ui/components/dialog';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useRepoData } from '@/hooks/useRepoData';
|
||||
import { useGQLClient } from '@/context';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Project } from '@workspace/gql-client';
|
||||
|
||||
// Import the tab content components
|
||||
import GitPage from './(integrations)/int/GitPage';
|
||||
import EnvVarsPage from './(settings)/set/(environment-variables)/env/EnvVarsPage';
|
||||
|
||||
export default function ProjectOverviewPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const client = useGQLClient();
|
||||
|
||||
// Safely unwrap params
|
||||
const id = params?.id ? String(params.id) : '';
|
||||
const provider = params?.provider ? String(params.provider) : '';
|
||||
|
||||
// Use the hook to get repo data
|
||||
const { repoData } = useRepoData(id);
|
||||
// State management
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('overview'); // Local tab state
|
||||
|
||||
// Default deployment details
|
||||
const [deploymentUrl, setDeploymentUrl] = useState('');
|
||||
const [deploymentDate, setDeploymentDate] = useState(Date.now() - 60 * 60 * 1000); // 1 hour ago
|
||||
const [deployedBy, setDeployedBy] = useState('');
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [branch, setBranch] = useState('main');
|
||||
// Deployment page state
|
||||
const [deployments, setDeployments] = useState<any[]>([]);
|
||||
const [filteredDeployments, setFilteredDeployments] = useState<any[]>([]);
|
||||
const [isLogsOpen, setIsLogsOpen] = useState(false);
|
||||
const [deploymentLogs, setDeploymentLogs] = useState<string>('');
|
||||
|
||||
// Update details when repo data is loaded
|
||||
// Load project data
|
||||
useEffect(() => {
|
||||
if (repoData) {
|
||||
setProjectName(repoData.name);
|
||||
setBranch(repoData.default_branch || 'main');
|
||||
setDeployedBy(repoData.owner?.login || 'username');
|
||||
// Create a deployment URL based on the repo name
|
||||
setDeploymentUrl(`https://${repoData.name.toLowerCase()}.example.com`);
|
||||
if (id) {
|
||||
loadProject(id);
|
||||
}
|
||||
}, [repoData]);
|
||||
}, [id]);
|
||||
|
||||
// Auction data
|
||||
const auctionId = 'laconic1sdfjwei4jfkasifgjiai45ioasjf5jjjafij355';
|
||||
// Load project from GraphQL
|
||||
const loadProject = async (projectId: string) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Activities data
|
||||
const activities = [
|
||||
{
|
||||
username: deployedBy || 'username',
|
||||
branch: branch,
|
||||
action: 'deploy: source cargo',
|
||||
time: '5 minutes ago'
|
||||
},
|
||||
{
|
||||
username: deployedBy || 'username',
|
||||
branch: branch,
|
||||
action: 'bump',
|
||||
time: '5 minutes ago'
|
||||
},
|
||||
{
|
||||
username: deployedBy || 'username',
|
||||
branch: branch,
|
||||
action: 'version: update version',
|
||||
time: '5 minutes ago'
|
||||
},
|
||||
{
|
||||
username: deployedBy || 'username',
|
||||
branch: branch,
|
||||
action: 'build: updates',
|
||||
time: '5 minutes ago'
|
||||
}
|
||||
];
|
||||
const response = await client.getProject(projectId);
|
||||
setProject(response.project);
|
||||
|
||||
// Handle tab changes by navigating to the correct folder
|
||||
const handleTabChange = (value: string) => {
|
||||
const basePath = `/projects/${provider}/ps/${id}`;
|
||||
|
||||
switch (value) {
|
||||
case 'overview':
|
||||
router.push(basePath);
|
||||
break;
|
||||
case 'deployment':
|
||||
router.push(`${basePath}/dep`);
|
||||
break;
|
||||
case 'settings':
|
||||
router.push(`${basePath}/set`);
|
||||
break;
|
||||
case 'git':
|
||||
router.push(`${basePath}/int`);
|
||||
break;
|
||||
case 'env-vars':
|
||||
router.push(`${basePath}/set/env`);
|
||||
break;
|
||||
// Set deployments for the deployment tab
|
||||
if (response.project?.deployments) {
|
||||
setDeployments(response.project.deployments);
|
||||
setFilteredDeployments(response.project.deployments);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load project:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load project');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh project data
|
||||
const handleRefresh = () => {
|
||||
if (id) {
|
||||
loadProject(id);
|
||||
}
|
||||
};
|
||||
|
||||
const currentDeployment = project?.deployments?.find((d: any) => d.isCurrent);
|
||||
const latestDeployment = project?.deployments?.[0]; // Assuming deployments are sorted by date
|
||||
|
||||
// Handle tab changes WITHOUT navigation - just update local state
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
};
|
||||
|
||||
// Helper function to safely parse dates
|
||||
const parseDate = (dateString: string | undefined) => {
|
||||
if (!dateString) return null;
|
||||
const date = new Date(dateString);
|
||||
return isNaN(date.getTime()) ? null : date.getTime();
|
||||
};
|
||||
|
||||
// Generate activities from deployments
|
||||
const generateActivities = () => {
|
||||
if (!project?.deployments) return [];
|
||||
|
||||
return project.deployments
|
||||
.slice(0, 4) // Show last 4 deployments
|
||||
.map((deployment: any) => ({
|
||||
username: deployment.createdBy?.name || 'Unknown',
|
||||
branch: deployment.branch,
|
||||
action: `deployed ${deployment.environment || 'production'}`,
|
||||
time: parseDate(deployment.createdAt) ? relativeTimeMs(parseDate(deployment.createdAt)!) : 'Unknown time',
|
||||
status: deployment.status
|
||||
}));
|
||||
};
|
||||
|
||||
const activities = generateActivities();
|
||||
|
||||
// Status badge component
|
||||
const StatusBadge = ({ status }: { status: string }) => {
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status?.toUpperCase()) {
|
||||
case 'COMPLETED':
|
||||
case 'READY':
|
||||
return 'bg-green-700/20 text-green-400';
|
||||
case 'BUILDING':
|
||||
case 'DEPLOYING':
|
||||
return 'bg-blue-700/20 text-blue-400';
|
||||
case 'ERROR':
|
||||
case 'FAILED':
|
||||
return 'bg-red-700/20 text-red-400';
|
||||
default:
|
||||
return 'bg-gray-700/20 text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`inline-block px-2 py-0.5 text-xs font-medium rounded ${getStatusColor(status)}`}>
|
||||
{status?.toUpperCase() || 'UNKNOWN'}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Handle deployment logs
|
||||
const handleViewLogs = () => {
|
||||
const mockLogs = `[2025-02-12 10:03:12] INFO Starting deployment process for service: ${project?.name}
|
||||
[2025-02-12 10:03:14] INFO Fetching latest commit from main branch (commit: a1b2c3d)
|
||||
[2025-02-12 10:03:15] INFO Building Docker image: registry.company.com/${project?.name}:latest
|
||||
[2025-02-12 10:03:26] INFO Running security scan on built image
|
||||
[2025-02-12 10:03:30] INFO Pushing image to container registry
|
||||
[2025-02-12 10:03:35] INFO Updating deployment configuration
|
||||
[2025-02-12 10:03:40] INFO Scaling down old pods
|
||||
[2025-02-12 10:03:42] INFO Scaling up new pods
|
||||
[2025-02-12 10:03:50] INFO Running health checks on new pods
|
||||
[2025-02-12 10:03:55] INFO Deployment completed successfully
|
||||
[2025-02-12 10:03:56] INFO Service is now live at ${currentDeployment?.applicationDeploymentRecordData?.url}`;
|
||||
|
||||
setDeploymentLogs(mockLogs);
|
||||
setIsLogsOpen(true);
|
||||
};
|
||||
|
||||
// Handle deployment deletion
|
||||
const handleDeleteDeployment = async (deploymentId: string, deploymentBranch: string) => {
|
||||
if (!confirm(`Are you sure you want to delete the deployment for branch "${deploymentBranch}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await client.deleteDeployment(deploymentId);
|
||||
|
||||
// Refresh the project data to update the deployments list
|
||||
await loadProject(id);
|
||||
|
||||
// Show success message (you could replace with a toast notification)
|
||||
alert('Deployment deleted successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete deployment:', error);
|
||||
alert('Failed to delete deployment. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle deployment rollback
|
||||
const handleRollbackDeployment = async (deploymentId: string, deploymentBranch: string) => {
|
||||
if (!project?.id) return;
|
||||
|
||||
if (!confirm(`Are you sure you want to rollback to the deployment for branch "${deploymentBranch}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await client.rollbackDeployment(project.id, deploymentId);
|
||||
|
||||
// Refresh the project data to update the deployments list
|
||||
await loadProject(id);
|
||||
|
||||
// Show success message
|
||||
alert('Deployment rollback completed successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to rollback deployment:', error);
|
||||
alert('Failed to rollback deployment. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Copy to clipboard function for settings
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
// You could add a toast notification here
|
||||
};
|
||||
|
||||
// Settings page state
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
|
||||
const showMessage = (message: string, isError = false) => {
|
||||
if (isError) {
|
||||
setErrorMessage(message);
|
||||
setSuccessMessage("");
|
||||
} else {
|
||||
setSuccessMessage(message);
|
||||
setErrorMessage("");
|
||||
}
|
||||
setTimeout(() => {
|
||||
setSuccessMessage("");
|
||||
setErrorMessage("");
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
// Handle project deletion
|
||||
const handleDeleteProject = async () => {
|
||||
if (!project) return;
|
||||
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
|
||||
await client.deleteProject(project.id);
|
||||
|
||||
showMessage("Project deleted successfully");
|
||||
setIsDeleteModalOpen(false);
|
||||
|
||||
// Navigate back to projects list after successful deletion
|
||||
setTimeout(() => {
|
||||
router.push('/projects');
|
||||
}, 1500);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to delete project:", error);
|
||||
showMessage("Failed to delete project", true);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsDeleteModalOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: 'Loading...',
|
||||
actions: []
|
||||
}}
|
||||
layout="bento"
|
||||
className="pb-0"
|
||||
>
|
||||
<div className="md:col-span-3 w-full flex items-center justify-center py-12">
|
||||
<div className="text-muted-foreground">Loading project data...</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error || !project) {
|
||||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: 'Project Not Found',
|
||||
actions: []
|
||||
}}
|
||||
layout="bento"
|
||||
className="pb-0"
|
||||
>
|
||||
<div className="md:col-span-3 w-full flex flex-col items-center justify-center py-12">
|
||||
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
|
||||
<div className="text-xl font-medium mb-2">Project not found</div>
|
||||
<div className="text-muted-foreground mb-4">
|
||||
{error ? `Error: ${error}` : 'The requested project could not be loaded.'}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => router.back()}>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button onClick={handleRefresh}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
header={{
|
||||
title: projectName || 'Project Overview',
|
||||
title: project.name || 'Project Overview',
|
||||
actions: [
|
||||
{
|
||||
label: 'Open repo',
|
||||
href: repoData?.html_url || '#',
|
||||
href: project.repository || '#',
|
||||
icon: 'external-link',
|
||||
external: true
|
||||
},
|
||||
{
|
||||
label: 'View app',
|
||||
href: deploymentUrl || '#',
|
||||
href: currentDeployment?.applicationDeploymentRecordData?.url || latestDeployment?.applicationDeploymentRecordData?.url || '#',
|
||||
icon: 'external-link',
|
||||
external: true
|
||||
}
|
||||
]
|
||||
}}
|
||||
layout="bento" // Use bento layout to override max width
|
||||
layout="bento"
|
||||
className="pb-0"
|
||||
>
|
||||
<div className="md:col-span-3 w-full"> {/* Take full width in bento grid */}
|
||||
{/* Tabs navigation */}
|
||||
<Tabs defaultValue="overview" className="w-full" onValueChange={handleTabChange}>
|
||||
<div className="md:col-span-3 w-full">
|
||||
{/* Tabs navigation - controlled locally */}
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="deployment">Deployment</TabsTrigger>
|
||||
@ -137,13 +343,23 @@ export default function ProjectOverviewPage() {
|
||||
<div className="p-6">
|
||||
<div className="flex items-center">
|
||||
<Avatar className="h-10 w-10 mr-4 bg-blue-600">
|
||||
<AvatarFallback>{getInitials(projectName || '')}</AvatarFallback>
|
||||
<AvatarFallback>{getInitials(project.name || '')}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<h2 className="text-lg font-medium">{projectName}</h2>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-medium">{project.name}</h2>
|
||||
{currentDeployment && (
|
||||
<StatusBadge status={currentDeployment.status} />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{deploymentUrl.replace(/^https?:\/\//, '')}
|
||||
{currentDeployment?.applicationDeploymentRecordData?.url?.replace(/^https?:\/\//, '') ||
|
||||
latestDeployment?.applicationDeploymentRecordData?.url?.replace(/^https?:\/\//, '') ||
|
||||
'No deployment URL'}
|
||||
</p>
|
||||
{project.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{project.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -151,78 +367,120 @@ export default function ProjectOverviewPage() {
|
||||
<div>
|
||||
<div className="flex items-center mb-2">
|
||||
<GitBranch className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||
<span className="text-muted-foreground text-sm">Source</span>
|
||||
<span className="text-muted-foreground text-sm">Production Branch</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<GitBranch className="h-4 w-4 mr-2" />
|
||||
<span>{branch}</span>
|
||||
<span>{project.prodBranch || 'main'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center mb-2">
|
||||
<ExternalLink className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||
<span className="text-muted-foreground text-sm">Deployment URL</span>
|
||||
<span className="text-muted-foreground text-sm">Repository</span>
|
||||
</div>
|
||||
<Link
|
||||
href={deploymentUrl}
|
||||
className="text-primary hover:underline flex items-center"
|
||||
target="_blank"
|
||||
>
|
||||
{deploymentUrl}
|
||||
</Link>
|
||||
{project.repository ? (
|
||||
<Link
|
||||
href={project.repository}
|
||||
className="text-primary hover:underline flex items-center"
|
||||
target="_blank"
|
||||
>
|
||||
{project.repository.replace('https://github.com/', '')}
|
||||
<ExternalLink className="h-3 w-3 ml-1" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-muted-foreground">No repository linked</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center mb-2">
|
||||
<Clock className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||
<span className="text-muted-foreground text-sm">Deployment date</span>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
|
||||
<div>
|
||||
<div className="flex items-center mb-2">
|
||||
<Clock className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||
<span className="text-muted-foreground text-sm">Last Deployment</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">
|
||||
{latestDeployment ?
|
||||
(parseDate(latestDeployment.createdAt) ?
|
||||
relativeTimeMs(parseDate(latestDeployment.createdAt)!) :
|
||||
'Invalid date') :
|
||||
'No deployments'
|
||||
}
|
||||
</span>
|
||||
{latestDeployment?.createdBy && (
|
||||
<>
|
||||
<span className="mr-2">by</span>
|
||||
<Avatar className="h-5 w-5 mr-2">
|
||||
<AvatarFallback>{getInitials(latestDeployment.createdBy.name || '')}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{latestDeployment.createdBy.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="mr-2">
|
||||
{relativeTimeMs(deploymentDate)}
|
||||
</span>
|
||||
<span className="mr-2">by</span>
|
||||
<Avatar className="h-5 w-5 mr-2">
|
||||
<AvatarFallback>{getInitials(deployedBy)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{deployedBy}</span>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center mb-2">
|
||||
<Activity className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||
<span className="text-muted-foreground text-sm">Framework</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Badge variant="secondary">{project.framework || 'Unknown'}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider between project info and auction details */}
|
||||
<div className="border-t border-border my-6"></div>
|
||||
|
||||
{/* Auction Details section */}
|
||||
{/* Deployment Details section */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium mb-6">Auction Details</h3>
|
||||
<h3 className="text-lg font-medium mb-6">Deployment Details</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="text-sm text-muted-foreground mb-1">Auction ID</h4>
|
||||
<p className="text-sm font-medium break-all">{auctionId}</p>
|
||||
<h4 className="text-sm text-muted-foreground mb-1">Project ID</h4>
|
||||
<p className="text-sm font-medium font-mono break-all">{project.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm text-muted-foreground mb-1">Auction Status</h4>
|
||||
<div className="inline-block px-2 py-0.5 bg-green-700/20 text-green-400 text-xs font-medium rounded">
|
||||
COMPLETED
|
||||
</div>
|
||||
<h4 className="text-sm text-muted-foreground mb-1">Organization</h4>
|
||||
<p className="text-sm font-medium">{project.organization?.name || 'Unknown'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
|
||||
<div>
|
||||
<h4 className="text-sm text-muted-foreground mb-1">Deployer LRNs</h4>
|
||||
<p className="text-sm font-medium break-all">{auctionId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm text-muted-foreground mb-1">Deployer Funds Status</h4>
|
||||
<div className="inline-block px-2 py-0.5 bg-blue-700/20 text-blue-400 text-xs font-medium rounded">
|
||||
RELEASED
|
||||
{project.auctionId && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
|
||||
<div>
|
||||
<h4 className="text-sm text-muted-foreground mb-1">Auction ID</h4>
|
||||
<p className="text-sm font-medium font-mono break-all">{project.auctionId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm text-muted-foreground mb-1">Funds Status</h4>
|
||||
<StatusBadge status={project.fundsReleased ? 'RELEASED' : 'PENDING'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.deployers && project.deployers.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h4 className="text-sm text-muted-foreground mb-3">Deployers ({project.deployers.length})</h4>
|
||||
<div className="space-y-2">
|
||||
{project.deployers.slice(0, 2).map((deployer: any, index: number) => (
|
||||
<div key={index} className="text-sm font-mono bg-muted p-2 rounded">
|
||||
{deployer.deployerLrn}
|
||||
</div>
|
||||
))}
|
||||
{project.deployers.length > 2 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
And {project.deployers.length - 2} more...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<Button variant="outline" size="sm">View details</Button>
|
||||
@ -231,7 +489,7 @@ export default function ProjectOverviewPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Activity section - not in a card */}
|
||||
{/* Activity section */}
|
||||
<div className="mt-8">
|
||||
<h3 className="text-lg font-medium mb-6 flex items-center">
|
||||
<Activity className="mr-2 h-4 w-4" />
|
||||
@ -239,29 +497,312 @@ export default function ProjectOverviewPage() {
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{activities.map((activity, index) => (
|
||||
<div key={index} className="flex items-start">
|
||||
<div className="text-muted-foreground mr-2">•</div>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm mr-2">{activity.username}</span>
|
||||
<GitBranch className="inline h-3 w-3 text-muted-foreground mx-1" />
|
||||
<span className="text-sm text-muted-foreground mr-2">{activity.branch}</span>
|
||||
<span className="text-sm text-muted-foreground">{activity.action}</span>
|
||||
{activities.length > 0 ? (
|
||||
activities.map((activity, index) => (
|
||||
<div key={index} className="flex items-start">
|
||||
<div className="text-muted-foreground mr-2">•</div>
|
||||
<div className="flex-1">
|
||||
<span className="text-sm mr-2">{activity.username}</span>
|
||||
<GitBranch className="inline h-3 w-3 text-muted-foreground mx-1" />
|
||||
<span className="text-sm text-muted-foreground mr-2">{activity.branch}</span>
|
||||
<span className="text-sm text-muted-foreground">{activity.action}</span>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{activity.time}</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{activity.time}</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-muted-foreground text-center py-8">
|
||||
No recent activity
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* These content sections won't be shown - we'll navigate to respective pages instead */}
|
||||
<TabsContent value="deployment"></TabsContent>
|
||||
<TabsContent value="settings"></TabsContent>
|
||||
<TabsContent value="git"></TabsContent>
|
||||
<TabsContent value="env-vars"></TabsContent>
|
||||
<TabsContent value="deployment" className="pt-6">
|
||||
<div className="space-y-6">
|
||||
{/* Filter Controls */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="relative flex-grow max-w-md">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Search className="h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search branches"
|
||||
className="pl-10"
|
||||
disabled={deployments.length === 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center opacity-60"
|
||||
disabled={deployments.length === 0}
|
||||
>
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
<span>Select a date</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex items-center opacity-60"
|
||||
disabled={deployments.length === 0}
|
||||
>
|
||||
<span>All Status</span>
|
||||
<ChevronDown className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Deployments List */}
|
||||
{filteredDeployments.length > 0 ? (
|
||||
filteredDeployments.map((deployment) => (
|
||||
<div key={deployment.id} className="border border-border rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
<span className="font-medium">{deployment.branch}</span>
|
||||
<StatusBadge status={deployment.status} />
|
||||
{deployment.isCurrent && (
|
||||
<Badge variant="default">Current</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleViewLogs(deployment.id)}
|
||||
>
|
||||
View logs
|
||||
</Button>
|
||||
{!deployment.isCurrent && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRollbackDeployment(deployment.id, deployment.branch)}
|
||||
>
|
||||
Rollback
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteDeployment(deployment.id, deployment.branch)}
|
||||
disabled={deployment.isCurrent}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">URL: </span>
|
||||
<Link
|
||||
href={deployment.applicationDeploymentRecordData?.url || '#'}
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
>
|
||||
{deployment.applicationDeploymentRecordData?.url}
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Created: </span>
|
||||
{parseDate(deployment.createdAt) ?
|
||||
relativeTimeMs(parseDate(deployment.createdAt)!) :
|
||||
'Unknown date'
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Commit: </span>
|
||||
<span className="font-mono">{deployment.commitHash?.substring(0, 8) || 'Unknown'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Created by: </span>
|
||||
{deployment.createdBy?.name || 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="h-96 border border-gray-800 rounded-lg flex flex-col items-center justify-center gap-5 text-center">
|
||||
<Square size={64} className="stroke-current" />
|
||||
<h2 className="text-xl font-semibold mb-2">You have no deployments</h2>
|
||||
<p className="text-gray-400 text-center max-w-md mb-6">
|
||||
Deploy your first version to see deployment history here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="settings" className="pt-6">
|
||||
{/* Updated theme-aware settings content */}
|
||||
<div className="space-y-8 w-full">
|
||||
{/* Success/Error Messages */}
|
||||
{successMessage && (
|
||||
<div className="bg-green-500/10 text-green-500 p-3 rounded-md border border-green-500/20">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<div className="bg-destructive/10 text-destructive p-3 rounded-md border border-destructive/20">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-border p-6 bg-card">
|
||||
<h2 className="text-xl font-semibold mb-4 text-card-foreground">Project Info</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="appName" className="text-card-foreground">
|
||||
App name
|
||||
</Label>
|
||||
<Input
|
||||
id="appName"
|
||||
value={project.name || ''}
|
||||
readOnly
|
||||
className="mt-1 bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description" className="text-card-foreground">
|
||||
Description
|
||||
</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={project.description || ''}
|
||||
readOnly
|
||||
className="mt-1 bg-muted"
|
||||
placeholder="No description"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="projectId" className="text-card-foreground">
|
||||
Project ID
|
||||
</Label>
|
||||
<div className="relative mt-1">
|
||||
<Input
|
||||
id="projectId"
|
||||
value={project.id}
|
||||
readOnly
|
||||
className="pr-10 bg-muted"
|
||||
/>
|
||||
<button
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 hover:text-foreground text-muted-foreground transition-colors"
|
||||
onClick={() => copyToClipboard(project.id)}
|
||||
aria-label="Copy project ID"
|
||||
>
|
||||
<Clipboard className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-card-foreground">Repository</Label>
|
||||
<Input
|
||||
value={project.repository || 'No repository linked'}
|
||||
readOnly
|
||||
className="mt-1 bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-card-foreground">Framework</Label>
|
||||
<Input
|
||||
value={project.framework || 'Unknown'}
|
||||
readOnly
|
||||
className="mt-1 bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-card-foreground">Production Branch</Label>
|
||||
<Input
|
||||
value={project.prodBranch || 'main'}
|
||||
readOnly
|
||||
className="mt-1 bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-card-foreground">Organization</Label>
|
||||
<Input
|
||||
value={project.organization?.name || 'Unknown'}
|
||||
readOnly
|
||||
className="mt-1 bg-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" disabled className="mt-4">
|
||||
Edit Settings (Coming Soon)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-destructive/50 p-6 bg-card">
|
||||
<h2 className="text-xl font-semibold mb-4 text-destructive">Delete Project</h2>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The project will be permanently deleted, including its deployments and domains. This action is
|
||||
irreversible and cannot be undone.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete Project"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="git" className="pt-6">
|
||||
<GitPage />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="env-vars" className="pt-6">
|
||||
<EnvVarsPage />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Dialog open={isDeleteModalOpen} onOpenChange={(open) => !isDeleting && setIsDeleteModalOpen(open)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you absolutely sure?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-foreground">
|
||||
This action cannot be undone. This will permanently delete the project{" "}
|
||||
<strong>"{project?.name}"</strong> and all associated deployments and domains.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsDeleteModalOpen(false)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteProject}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
@ -1,72 +1,85 @@
|
||||
'use client'
|
||||
import { PageWrapper } from '@/components/foundation'
|
||||
import CheckBalanceIframe from '@/components/iframe/check-balance-iframe/CheckBalanceIframe'
|
||||
import type { Project } from '@octokit/webhooks-types'
|
||||
import { FixedProjectCard } from '@/components/projects/project/ProjectCard/FixedProjectCard'
|
||||
import { Button } from '@workspace/ui/components/button'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Shapes } from 'lucide-react'
|
||||
import { useAuth, useUser } from '@clerk/nextjs'
|
||||
import { useRepoData } from '@/hooks/useRepoData'
|
||||
import { useGQLClient } from '@/context'
|
||||
import type { Project } from '@workspace/gql-client'
|
||||
|
||||
interface ProjectData {
|
||||
id: string
|
||||
name: string
|
||||
icon?: string
|
||||
deployments: any[]
|
||||
// Additional fields from GitHub repo
|
||||
full_name?: string
|
||||
html_url?: string
|
||||
updated_at?: string
|
||||
default_branch?: string
|
||||
repository?: string
|
||||
framework?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [, setIsBalanceSufficient] = useState<boolean>()
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [projects, setProjects] = useState<ProjectData[]>([])
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const { isLoaded: isAuthLoaded, userId } = useAuth()
|
||||
const { isLoaded: isUserLoaded, user } = useUser()
|
||||
const client = useGQLClient()
|
||||
|
||||
// Use the hook to fetch all repos (with an empty ID to get all)
|
||||
const { repoData: allRepos, isLoading: reposLoading, error: reposError } = useRepoData('');
|
||||
|
||||
const handleConnectGitHub = () => {
|
||||
window.open('https://accounts.clerk.dev/user', '_blank');
|
||||
const handleCreateProject = () => {
|
||||
window.location.href = '/projects/github/ps/cr'
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Process repos data when it's loaded
|
||||
if (!reposLoading && allRepos) {
|
||||
// Transform GitHub repos to match ProjectData interface
|
||||
const projectData: ProjectData[] = allRepos.map((repo: any) => ({
|
||||
id: repo.id.toString(),
|
||||
name: repo.name,
|
||||
full_name: repo.full_name,
|
||||
// Create a deployment object that matches your existing structure
|
||||
deployments: [
|
||||
{
|
||||
applicationDeploymentRecordData: {
|
||||
url: repo.html_url
|
||||
},
|
||||
branch: repo.default_branch,
|
||||
createdAt: repo.updated_at,
|
||||
createdBy: {
|
||||
name: repo.owner?.login || 'Unknown'
|
||||
}
|
||||
}
|
||||
]
|
||||
}));
|
||||
loadAllProjects()
|
||||
}, [])
|
||||
|
||||
setProjects(projectData);
|
||||
setIsLoading(false);
|
||||
} else if (!reposLoading && reposError) {
|
||||
setError(reposError);
|
||||
setIsLoading(false);
|
||||
const loadAllProjects = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
// First get organizations
|
||||
const orgsResponse = await client.getOrganizations()
|
||||
|
||||
if (!orgsResponse.organizations || orgsResponse.organizations.length === 0) {
|
||||
setProjects([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Get projects from all organizations
|
||||
const allProjects: ProjectData[] = []
|
||||
|
||||
for (const org of orgsResponse.organizations) {
|
||||
try {
|
||||
const projectsResponse = await client.getProjectsInOrganization(org.slug)
|
||||
|
||||
// Transform GraphQL projects to match ProjectData interface
|
||||
const orgProjects: ProjectData[] = projectsResponse.projectsInOrganization.map((project: Project) => ({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
repository: project.repository,
|
||||
framework: project.framework,
|
||||
description: project.description,
|
||||
deployments: project.deployments || []
|
||||
}))
|
||||
|
||||
allProjects.push(...orgProjects)
|
||||
} catch (orgError) {
|
||||
console.error(`Failed to load projects for org ${org.slug}:`, orgError)
|
||||
// Continue with other orgs even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
setProjects(allProjects)
|
||||
} catch (err) {
|
||||
console.error('Failed to load projects:', err)
|
||||
setError(err instanceof Error ? err.message : 'Failed to load projects')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [allRepos, reposLoading, reposError]);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
@ -92,16 +105,13 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Error: {error}</h2>
|
||||
<p className="text-gray-400 text-center max-w-md mb-6">
|
||||
Please connect your GitHub account to see your repositories.
|
||||
Failed to load your deployed projects. Please try again.
|
||||
</p>
|
||||
<Button
|
||||
className="bg-white text-black hover:bg-gray-200 flex items-center"
|
||||
onClick={handleConnectGitHub}
|
||||
onClick={loadAllProjects}
|
||||
>
|
||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
||||
</svg>
|
||||
Connect to GitHub
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
@ -114,29 +124,77 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Deploy your first app</h2>
|
||||
<p className="text-gray-400 text-center max-w-md mb-6">
|
||||
Once connected, you can import a repository from your account or start with one of our templates.
|
||||
You don't have any deployed projects yet. Create your first project to get started.
|
||||
</p>
|
||||
<Button
|
||||
className="bg-white text-black hover:bg-gray-200 flex items-center"
|
||||
onClick={handleConnectGitHub}
|
||||
onClick={handleCreateProject}
|
||||
>
|
||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
||||
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
Connect to GitHub
|
||||
Create Project
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
// Custom grid that spans the entire bento layout
|
||||
<div className="md:col-span-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{projects.map((project) => (
|
||||
<FixedProjectCard
|
||||
project={project as any}
|
||||
key={project.id}
|
||||
status={project.deployments[0]?.branch ? 'success' : 'pending'}
|
||||
/>
|
||||
))}
|
||||
{projects.map((project) => {
|
||||
// Get the current deployment for status
|
||||
const currentDeployment = project.deployments.find(d => d.isCurrent)
|
||||
const latestDeployment = project.deployments[0] // Assuming sorted by date
|
||||
|
||||
// Determine status based on deployment
|
||||
let status = 'pending'
|
||||
if (currentDeployment || latestDeployment) {
|
||||
const deployment = currentDeployment || latestDeployment
|
||||
switch (deployment.status?.toUpperCase()) {
|
||||
case 'READY':
|
||||
case 'COMPLETED':
|
||||
status = 'success'
|
||||
break
|
||||
case 'BUILDING':
|
||||
case 'DEPLOYING':
|
||||
status = 'in-progress'
|
||||
break
|
||||
case 'ERROR':
|
||||
case 'FAILED':
|
||||
status = 'failure'
|
||||
break
|
||||
default:
|
||||
status = 'pending'
|
||||
}
|
||||
}
|
||||
|
||||
// Format the project data to match what FixedProjectCard expects
|
||||
const formattedProject = {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
full_name: project.repository ? project.repository.replace('https://github.com/', '') : project.name,
|
||||
repository: project.repository,
|
||||
framework: project.framework,
|
||||
description: project.description,
|
||||
// Ensure deployments array is properly formatted
|
||||
deployments: project.deployments.map(deployment => ({
|
||||
...deployment,
|
||||
// Make sure the date is in a format the card can parse
|
||||
createdAt: deployment.createdAt,
|
||||
applicationDeploymentRecordData: {
|
||||
url: deployment.applicationDeploymentRecordData?.url || `https://${project.name.toLowerCase()}.example.com`
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<FixedProjectCard
|
||||
project={formattedProject}
|
||||
key={project.id}
|
||||
status={status as any}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
166
apps/deploy-fe/src/app/auth/github/backend-callback/page.tsx
Normal file
166
apps/deploy-fe/src/app/auth/github/backend-callback/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -4,6 +4,7 @@ import '@workspace/ui/globals.css'
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import { CheckBalanceWrapper } from '@/components/iframe/check-balance-iframe/CheckBalanceWrapper'
|
||||
import { AutoSignInIFrameModal } from '@/components/iframe/auto-sign-in'
|
||||
|
||||
// Add root metadata with template pattern
|
||||
export const metadata: Metadata = {
|
||||
@ -29,6 +30,9 @@ export default function RootLayout({
|
||||
<body className={`${inter.className} `} suppressHydrationWarning>
|
||||
<main>
|
||||
<Providers>{children}</Providers>
|
||||
<div style={{ display: 'none' }}>
|
||||
<AutoSignInIFrameModal />
|
||||
</div>
|
||||
<CheckBalanceWrapper />
|
||||
</main>
|
||||
</body>
|
||||
|
882
apps/deploy-fe/src/app/test-connection/page.tsx
Normal file
882
apps/deploy-fe/src/app/test-connection/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
411
apps/deploy-fe/src/components/AuthTest.tsx
Normal file
411
apps/deploy-fe/src/components/AuthTest.tsx
Normal 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>
|
||||
)
|
||||
}
|
171
apps/deploy-fe/src/components/DeploymentTest.tsx
Normal file
171
apps/deploy-fe/src/components/DeploymentTest.tsx
Normal 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>
|
||||
)
|
||||
}
|
160
apps/deploy-fe/src/components/DirectKeyAuth.tsx
Normal file
160
apps/deploy-fe/src/components/DirectKeyAuth.tsx
Normal 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>
|
||||
)
|
||||
}
|
93
apps/deploy-fe/src/components/GQLTest.tsx
Normal file
93
apps/deploy-fe/src/components/GQLTest.tsx
Normal 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>
|
||||
)
|
||||
}
|
241
apps/deploy-fe/src/components/GitHubBackendAuth.tsx
Normal file
241
apps/deploy-fe/src/components/GitHubBackendAuth.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -10,7 +10,7 @@ import {
|
||||
SheetTitle,
|
||||
SheetTrigger
|
||||
} from '@workspace/ui/components/sheet'
|
||||
import { CreditCard, Menu, Shapes, WalletIcon } from 'lucide-react'
|
||||
import { CreditCard, Menu, Shapes } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import type React from 'react'
|
||||
import { DarkModeToggle } from '../dark-mode-toggle'
|
||||
@ -202,10 +202,9 @@ export default function TopNavigation({
|
||||
config = {
|
||||
leftItems: [
|
||||
{ icon: Shapes, label: 'Projects', href: '/projects' },
|
||||
{ icon: WalletIcon, label: 'Wallet', href: '/wallet' },
|
||||
{ icon: CreditCard, label: 'Purchase', href: '/purchase' }
|
||||
],
|
||||
rightItems: [
|
||||
{ icon: CreditCard, label: 'Purchase', href: '/purchase' },
|
||||
{ label: 'Support', href: '/support' },
|
||||
{ label: 'Documentation', href: '/documentation' }
|
||||
]
|
||||
|
@ -67,7 +67,6 @@
|
||||
// }
|
||||
|
||||
'use client'
|
||||
|
||||
import { useWallet } from '@/context/WalletContext'
|
||||
import { Button } from '@workspace/ui/components/button'
|
||||
import {
|
||||
@ -78,36 +77,16 @@ import {
|
||||
} from '@workspace/ui/components/dropdown-menu'
|
||||
import { cn } from '@workspace/ui/lib/utils'
|
||||
import { ChevronDown, LogOut } from 'lucide-react'
|
||||
import useCheckBalance from '@/components/iframe/check-balance-iframe/useCheckBalance'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
const IFRAME_ID = 'checkBalanceIframe'
|
||||
|
||||
export function WalletSessionBadge({ className }: { className?: string }) {
|
||||
export function WalletSessionBadge() {
|
||||
const { wallet, isConnected, connect, disconnect } = useWallet()
|
||||
const { isBalanceSufficient, checkBalance } = useCheckBalance("1", IFRAME_ID)
|
||||
|
||||
// Check balance when wallet connects
|
||||
useEffect(() => {
|
||||
if (isConnected) {
|
||||
checkBalance()
|
||||
}
|
||||
}, [isConnected, checkBalance])
|
||||
|
||||
// Format address for display (first 6 chars + ... + last 4 chars)
|
||||
// Format address for display
|
||||
const formatAddress = (address?: string) => {
|
||||
if (!address) return 'Connect Wallet'
|
||||
return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`
|
||||
}
|
||||
|
||||
// Determine the status indicator color based on connection and balance
|
||||
const getStatusColor = () => {
|
||||
if (!isConnected) return 'bg-red-500'
|
||||
if (isBalanceSufficient === false) return 'bg-yellow-500'
|
||||
if (isBalanceSufficient === true) return 'bg-green-500'
|
||||
return 'bg-blue-500' // Checking balance
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@ -116,41 +95,37 @@ export function WalletSessionBadge({ className }: { className?: string }) {
|
||||
className={cn(
|
||||
'flex items-center space-x-2 rounded-md border px-3 py-1.5 text-sm font-medium',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'dark:bg-accent/5 dark:hover:bg-accent/10',
|
||||
className
|
||||
'dark:bg-accent/5 dark:hover:bg-accent/10'
|
||||
)}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="relative flex h-2 w-2 mr-2">
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inline-flex h-full w-full animate-ping rounded-full opacity-75',
|
||||
getStatusColor()
|
||||
isConnected ? 'bg-green-400' : 'bg-red-400'
|
||||
)}
|
||||
/>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex h-2 w-2 rounded-full',
|
||||
getStatusColor()
|
||||
isConnected ? 'bg-green-500' : 'bg-red-500'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span>
|
||||
{isConnected && wallet?.address
|
||||
? formatAddress(wallet.address)
|
||||
: 'Connect Wallet'}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<span>{isConnected && wallet?.address
|
||||
? formatAddress(wallet.address)
|
||||
: 'Connect Wallet'}</span>
|
||||
<ChevronDown className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="text-sm font-medium">Connected to:</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{wallet?.address}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Balance: {isBalanceSufficient === undefined ? 'Checking...' :
|
||||
isBalanceSufficient ? 'Sufficient' : 'Insufficient'}
|
||||
<p className="text-sm font-medium">Connected Wallet</p>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate max-w-[200px]">
|
||||
{wallet?.address}
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenuItem
|
||||
|
@ -29,8 +29,8 @@
|
||||
// }
|
||||
|
||||
'use client'
|
||||
|
||||
import { useWallet } from '@/context/WalletContext' // or WalletContextProvider
|
||||
import { useState } from 'react'
|
||||
import { useWallet } from '@/context/WalletContextProvider'
|
||||
import { Button } from '@workspace/ui/components/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -41,10 +41,17 @@ import {
|
||||
import { cn } from '@workspace/ui/lib/utils'
|
||||
import { ChevronDown, LogOut } from 'lucide-react'
|
||||
|
||||
export function WalletSessionBadge({ className }: { className?: string }) {
|
||||
export function WalletSessionBadge() {
|
||||
const { wallet, isConnected, connect, disconnect } = useWallet()
|
||||
const [showAuthModal, setShowAuthModal] = useState(false)
|
||||
|
||||
// Format address for display (first 6 chars + ... + last 4 chars)
|
||||
const handleConnect = () => {
|
||||
// Instead of directly showing the modal, call the connect function
|
||||
// from WalletContext which should now use the iframe messaging
|
||||
connect()
|
||||
}
|
||||
|
||||
// Format address for display
|
||||
const formatAddress = (address?: string) => {
|
||||
if (!address) return 'Connect Wallet'
|
||||
return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`
|
||||
@ -58,8 +65,7 @@ export function WalletSessionBadge({ className }: { className?: string }) {
|
||||
className={cn(
|
||||
'flex items-center space-x-2 rounded-md border px-3 py-1.5 text-sm font-medium',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'dark:bg-accent/5 dark:hover:bg-accent/10',
|
||||
className
|
||||
'dark:bg-accent/5 dark:hover:bg-accent/10'
|
||||
)}
|
||||
>
|
||||
<span className="relative flex h-2 w-2">
|
||||
@ -69,6 +75,7 @@ export function WalletSessionBadge({ className }: { className?: string }) {
|
||||
isConnected ? 'bg-green-400' : 'bg-red-400'
|
||||
)}
|
||||
/>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex h-2 w-2 rounded-full',
|
||||
@ -94,7 +101,7 @@ export function WalletSessionBadge({ className }: { className?: string }) {
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
className="flex cursor-pointer items-center"
|
||||
onClick={connect}
|
||||
onClick={handleConnect}
|
||||
>
|
||||
<span>Connect Wallet</span>
|
||||
</DropdownMenuItem>
|
||||
|
@ -1,63 +1,213 @@
|
||||
// 'use client'
|
||||
// import { useCallback, useEffect, useState } from 'react'
|
||||
// // Commenting out these imports as they cause linter errors due to missing dependencies
|
||||
// // In an actual implementation, these would be properly installed
|
||||
// // import { generateNonce, SiweMessage } from 'siwe'
|
||||
// // import axios from 'axios'
|
||||
|
||||
// // Define proper types to replace 'any'
|
||||
// interface SiweMessageProps {
|
||||
// version: string
|
||||
// domain: string
|
||||
// uri: string
|
||||
// chainId: number
|
||||
// address: string
|
||||
// nonce: string
|
||||
// statement: string
|
||||
// }
|
||||
|
||||
// interface ValidateRequestData {
|
||||
// message: string
|
||||
// signature: string
|
||||
// }
|
||||
|
||||
// // Mock implementations to demonstrate functionality without dependencies
|
||||
// // In a real project, use the actual dependencies
|
||||
// const generateNonce = () => Math.random().toString(36).substring(2, 15)
|
||||
// const SiweMessage = class {
|
||||
// constructor(props: SiweMessageProps) {
|
||||
// this.props = props
|
||||
// }
|
||||
// props: SiweMessageProps
|
||||
// prepareMessage() {
|
||||
// return JSON.stringify(this.props)
|
||||
// }
|
||||
// }
|
||||
|
||||
// // Access environment variables from .env.local with fallbacks for safety
|
||||
// // In a production environment, these would be properly configured
|
||||
// const WALLET_IFRAME_URL =
|
||||
// process.env.NEXT_PUBLIC_WALLET_IFRAME_URL || 'https://wallet.example.com'
|
||||
|
||||
// // Mock axios implementation
|
||||
// const axiosInstance = {
|
||||
// post: async (url: string, data: ValidateRequestData) => {
|
||||
// console.log('Mock API call to', url, 'with data', data)
|
||||
// return { data: { success: true } }
|
||||
// }
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * AutoSignInIFrameModal component that handles wallet authentication through an iframe.
|
||||
// * This component is responsible for:
|
||||
// * 1. Getting the wallet address
|
||||
// * 2. Creating a Sign-In With Ethereum message
|
||||
// * 3. Requesting signature from the wallet
|
||||
// * 4. Validating the signature with the backend
|
||||
// *
|
||||
// * @returns {JSX.Element} A modal with an iframe for wallet authentication
|
||||
// */
|
||||
// export function AutoSignInIFrameModal() {
|
||||
// const [accountAddress, setAccountAddress] = useState<string>()
|
||||
|
||||
// // Handle sign-in response from the wallet iframe
|
||||
// useEffect(() => {
|
||||
// const handleSignInResponse = async (event: MessageEvent) => {
|
||||
// if (event.origin !== WALLET_IFRAME_URL) return
|
||||
|
||||
// if (event.data.type === 'SIGN_IN_RESPONSE') {
|
||||
// try {
|
||||
// const response = await axiosInstance.post('/auth/validate', {
|
||||
// message: event.data.data.message,
|
||||
// signature: event.data.data.signature
|
||||
// })
|
||||
|
||||
// if (response.data.success === true) {
|
||||
// // In Next.js, we would use router.push instead
|
||||
// window.location.href = '/'
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error('Error signing in:', error)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// window.addEventListener('message', handleSignInResponse)
|
||||
|
||||
// return () => {
|
||||
// window.removeEventListener('message', handleSignInResponse)
|
||||
// }
|
||||
// }, [])
|
||||
|
||||
// // Initiate auto sign-in when account address is available
|
||||
// useEffect(() => {
|
||||
// const initiateAutoSignIn = async () => {
|
||||
// if (!accountAddress) return
|
||||
|
||||
// const iframe = document.getElementById(
|
||||
// 'walletAuthFrame'
|
||||
// ) as HTMLIFrameElement
|
||||
|
||||
// if (!iframe.contentWindow) {
|
||||
// console.error('Iframe not found or not loaded')
|
||||
// return
|
||||
// }
|
||||
|
||||
// const message = new SiweMessage({
|
||||
// version: '1',
|
||||
// domain: window.location.host,
|
||||
// uri: window.location.origin,
|
||||
// chainId: 1,
|
||||
// address: accountAddress,
|
||||
// nonce: generateNonce(),
|
||||
// statement: 'Sign in With Ethereum.'
|
||||
// }).prepareMessage()
|
||||
|
||||
// iframe.contentWindow.postMessage(
|
||||
// {
|
||||
// type: 'AUTO_SIGN_IN',
|
||||
// chainId: '1',
|
||||
// message
|
||||
// },
|
||||
// WALLET_IFRAME_URL
|
||||
// )
|
||||
// }
|
||||
|
||||
// initiateAutoSignIn()
|
||||
// }, [accountAddress])
|
||||
|
||||
// // Listen for wallet accounts data
|
||||
// useEffect(() => {
|
||||
// const handleAccountsDataResponse = async (event: MessageEvent) => {
|
||||
// if (event.origin !== WALLET_IFRAME_URL) return
|
||||
|
||||
// if (
|
||||
// event.data.type === 'WALLET_ACCOUNTS_DATA' &&
|
||||
// event.data.data?.length > 0
|
||||
// ) {
|
||||
// setAccountAddress(event.data.data[0].address)
|
||||
// }
|
||||
// }
|
||||
|
||||
// window.addEventListener('message', handleAccountsDataResponse)
|
||||
|
||||
// return () => {
|
||||
// window.removeEventListener('message', handleAccountsDataResponse)
|
||||
// }
|
||||
// }, [])
|
||||
|
||||
// // Request wallet address when iframe is loaded
|
||||
// const getAddressFromWallet = useCallback(() => {
|
||||
// const iframe = document.getElementById(
|
||||
// 'walletAuthFrame'
|
||||
// ) as HTMLIFrameElement
|
||||
|
||||
// if (!iframe.contentWindow) {
|
||||
// console.error('Iframe not found or not loaded')
|
||||
// return
|
||||
// }
|
||||
|
||||
// iframe.contentWindow.postMessage(
|
||||
// {
|
||||
// type: 'REQUEST_CREATE_OR_GET_ACCOUNTS',
|
||||
// chainId: '1'
|
||||
// },
|
||||
// WALLET_IFRAME_URL
|
||||
// )
|
||||
// }, [])
|
||||
|
||||
// return (
|
||||
// <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
// <div className="relative w-[90%] max-w-6xl h-[600px] max-h-[80vh] overflow-auto rounded-lg bg-white shadow-lg">
|
||||
// <iframe
|
||||
// onLoad={getAddressFromWallet}
|
||||
// id="walletAuthFrame"
|
||||
// src={`${WALLET_IFRAME_URL}/auto-sign-in`}
|
||||
// className="w-full h-full"
|
||||
// sandbox="allow-scripts allow-same-origin"
|
||||
// title="Wallet Authentication"
|
||||
// />
|
||||
// </div>
|
||||
// </div>
|
||||
// )
|
||||
// }
|
||||
|
||||
// src/components/iframe/auto-sign-in/AutoSignInIFrameModal.tsx
|
||||
'use client'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
// Commenting out these imports as they cause linter errors due to missing dependencies
|
||||
// In an actual implementation, these would be properly installed
|
||||
// import { generateNonce, SiweMessage } from 'siwe'
|
||||
// import axios from 'axios'
|
||||
import { generateNonce, SiweMessage } from 'siwe'
|
||||
import axios from 'axios'
|
||||
|
||||
// Define proper types to replace 'any'
|
||||
interface SiweMessageProps {
|
||||
version: string
|
||||
domain: string
|
||||
uri: string
|
||||
chainId: number
|
||||
address: string
|
||||
nonce: string
|
||||
statement: string
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
},
|
||||
withCredentials: true
|
||||
})
|
||||
|
||||
const WALLET_IFRAME_URL = process.env.NEXT_PUBLIC_WALLET_IFRAME_URL || 'http://localhost:4000'
|
||||
|
||||
interface AutoSignInProps {
|
||||
onAuthComplete?: (success: boolean) => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
interface ValidateRequestData {
|
||||
message: string
|
||||
signature: string
|
||||
}
|
||||
|
||||
// Mock implementations to demonstrate functionality without dependencies
|
||||
// In a real project, use the actual dependencies
|
||||
const generateNonce = () => Math.random().toString(36).substring(2, 15)
|
||||
const SiweMessage = class {
|
||||
constructor(props: SiweMessageProps) {
|
||||
this.props = props
|
||||
}
|
||||
props: SiweMessageProps
|
||||
prepareMessage() {
|
||||
return JSON.stringify(this.props)
|
||||
}
|
||||
}
|
||||
|
||||
// Access environment variables from .env.local with fallbacks for safety
|
||||
// In a production environment, these would be properly configured
|
||||
const WALLET_IFRAME_URL =
|
||||
process.env.NEXT_PUBLIC_WALLET_IFRAME_URL || 'https://wallet.example.com'
|
||||
|
||||
// Mock axios implementation
|
||||
const axiosInstance = {
|
||||
post: async (url: string, data: ValidateRequestData) => {
|
||||
console.log('Mock API call to', url, 'with data', data)
|
||||
return { data: { success: true } }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AutoSignInIFrameModal component that handles wallet authentication through an iframe.
|
||||
* This component is responsible for:
|
||||
* 1. Getting the wallet address
|
||||
* 2. Creating a Sign-In With Ethereum message
|
||||
* 3. Requesting signature from the wallet
|
||||
* 4. Validating the signature with the backend
|
||||
*
|
||||
* @returns {JSX.Element} A modal with an iframe for wallet authentication
|
||||
*/
|
||||
export function AutoSignInIFrameModal() {
|
||||
export function AutoSignInIFrameModal({ onAuthComplete, onClose }: AutoSignInProps = {}) {
|
||||
const [accountAddress, setAccountAddress] = useState<string>()
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
const [authStatus, setAuthStatus] = useState<'idle' | 'connecting' | 'signing' | 'success' | 'error'>('idle')
|
||||
|
||||
// Handle sign-in response from the wallet iframe
|
||||
useEffect(() => {
|
||||
@ -66,26 +216,70 @@ export function AutoSignInIFrameModal() {
|
||||
|
||||
if (event.data.type === 'SIGN_IN_RESPONSE') {
|
||||
try {
|
||||
setAuthStatus('signing')
|
||||
console.log('🔐 Validating SIWE signature...')
|
||||
|
||||
const response = await axiosInstance.post('/auth/validate', {
|
||||
message: event.data.data.message,
|
||||
signature: event.data.data.signature
|
||||
})
|
||||
|
||||
if (response.data.success === true) {
|
||||
// In Next.js, we would use router.push instead
|
||||
window.location.href = '/'
|
||||
console.log('✅ SIWE authentication successful!')
|
||||
setAuthStatus('success')
|
||||
|
||||
// Notify parent component instead of redirecting
|
||||
onAuthComplete?.(true)
|
||||
|
||||
// Close modal after a brief delay
|
||||
setTimeout(() => {
|
||||
setIsVisible(false)
|
||||
onClose?.()
|
||||
}, 1000)
|
||||
} else {
|
||||
console.error('❌ SIWE authentication failed')
|
||||
setAuthStatus('error')
|
||||
onAuthComplete?.(false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error signing in:', error)
|
||||
console.error('❌ Error during SIWE validation:', error)
|
||||
setAuthStatus('error')
|
||||
onAuthComplete?.(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleSignInResponse)
|
||||
return () => window.removeEventListener('message', handleSignInResponse)
|
||||
}, [onAuthComplete, onClose])
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleSignInResponse)
|
||||
// Listen for wallet accounts data
|
||||
useEffect(() => {
|
||||
const handleAccountsDataResponse = async (event: MessageEvent) => {
|
||||
if (event.origin !== WALLET_IFRAME_URL) return
|
||||
|
||||
if (event.data.type === 'WALLET_ACCOUNTS_DATA' && event.data.data?.length > 0) {
|
||||
let address;
|
||||
|
||||
// Handle multiple data formats
|
||||
if (Array.isArray(event.data.data)) {
|
||||
if (typeof event.data.data[0] === 'string') {
|
||||
address = event.data.data[0];
|
||||
} else if (event.data.data[0] && typeof event.data.data[0].address === 'string') {
|
||||
address = event.data.data[0].address;
|
||||
}
|
||||
}
|
||||
|
||||
if (address) {
|
||||
console.log('📱 Got wallet address for SIWE:', address)
|
||||
setAccountAddress(address)
|
||||
setAuthStatus('connecting')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleAccountsDataResponse)
|
||||
return () => window.removeEventListener('message', handleAccountsDataResponse)
|
||||
}, [])
|
||||
|
||||
// Initiate auto sign-in when account address is available
|
||||
@ -93,12 +287,13 @@ export function AutoSignInIFrameModal() {
|
||||
const initiateAutoSignIn = async () => {
|
||||
if (!accountAddress) return
|
||||
|
||||
const iframe = document.getElementById(
|
||||
'walletAuthFrame'
|
||||
) as HTMLIFrameElement
|
||||
console.log('🔐 Starting SIWE authentication for:', accountAddress)
|
||||
|
||||
if (!iframe.contentWindow) {
|
||||
console.error('Iframe not found or not loaded')
|
||||
const iframe = document.getElementById('walletAuthFrame') as HTMLIFrameElement
|
||||
|
||||
if (!iframe?.contentWindow) {
|
||||
console.error('❌ walletAuthFrame iframe not found')
|
||||
setAuthStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
@ -112,6 +307,8 @@ export function AutoSignInIFrameModal() {
|
||||
statement: 'Sign in With Ethereum.'
|
||||
}).prepareMessage()
|
||||
|
||||
console.log('📝 SIWE message created, requesting signature...')
|
||||
|
||||
iframe.contentWindow.postMessage(
|
||||
{
|
||||
type: 'AUTO_SIGN_IN',
|
||||
@ -122,40 +319,23 @@ export function AutoSignInIFrameModal() {
|
||||
)
|
||||
}
|
||||
|
||||
initiateAutoSignIn()
|
||||
}, [accountAddress])
|
||||
|
||||
// Listen for wallet accounts data
|
||||
useEffect(() => {
|
||||
const handleAccountsDataResponse = async (event: MessageEvent) => {
|
||||
if (event.origin !== WALLET_IFRAME_URL) return
|
||||
|
||||
if (
|
||||
event.data.type === 'WALLET_ACCOUNTS_DATA' &&
|
||||
event.data.data?.length > 0
|
||||
) {
|
||||
setAccountAddress(event.data.data[0].address)
|
||||
}
|
||||
if (accountAddress && authStatus === 'connecting') {
|
||||
setTimeout(initiateAutoSignIn, 500)
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleAccountsDataResponse)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleAccountsDataResponse)
|
||||
}
|
||||
}, [])
|
||||
}, [accountAddress, authStatus])
|
||||
|
||||
// Request wallet address when iframe is loaded
|
||||
const getAddressFromWallet = useCallback(() => {
|
||||
const iframe = document.getElementById(
|
||||
'walletAuthFrame'
|
||||
) as HTMLIFrameElement
|
||||
const iframe = document.getElementById('walletAuthFrame') as HTMLIFrameElement
|
||||
|
||||
if (!iframe.contentWindow) {
|
||||
console.error('Iframe not found or not loaded')
|
||||
if (!iframe?.contentWindow) {
|
||||
console.error('❌ Iframe not found or not loaded')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('📤 Requesting wallet address for SIWE...')
|
||||
setAuthStatus('idle')
|
||||
|
||||
iframe.contentWindow.postMessage(
|
||||
{
|
||||
type: 'REQUEST_CREATE_OR_GET_ACCOUNTS',
|
||||
@ -165,9 +345,61 @@ export function AutoSignInIFrameModal() {
|
||||
)
|
||||
}, [])
|
||||
|
||||
// Don't render if not visible
|
||||
if (!isVisible) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="relative w-[90%] max-w-6xl h-[600px] max-h-[80vh] overflow-auto rounded-lg bg-white shadow-lg">
|
||||
{/* Status indicator */}
|
||||
<div className="absolute top-4 left-4 z-10 bg-white/90 px-3 py-2 rounded-md shadow-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
{authStatus === 'idle' && (
|
||||
<>
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full"></div>
|
||||
<span className="text-sm text-gray-600">Initializing...</span>
|
||||
</>
|
||||
)}
|
||||
{authStatus === 'connecting' && (
|
||||
<>
|
||||
<div className="w-2 h-2 bg-blue-400 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm text-blue-600">Connecting wallet...</span>
|
||||
</>
|
||||
)}
|
||||
{authStatus === 'signing' && (
|
||||
<>
|
||||
<div className="w-2 h-2 bg-yellow-400 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm text-yellow-600">Signing message...</span>
|
||||
</>
|
||||
)}
|
||||
{authStatus === 'success' && (
|
||||
<>
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full"></div>
|
||||
<span className="text-sm text-green-600">Authentication complete!</span>
|
||||
</>
|
||||
)}
|
||||
{authStatus === 'error' && (
|
||||
<>
|
||||
<div className="w-2 h-2 bg-red-400 rounded-full"></div>
|
||||
<span className="text-sm text-red-600">Authentication failed</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsVisible(false)
|
||||
onClose()
|
||||
}}
|
||||
className="absolute top-4 right-4 z-10 bg-white/90 hover:bg-white px-2 py-1 rounded-md shadow-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
|
||||
<iframe
|
||||
onLoad={getAddressFromWallet}
|
||||
id="walletAuthFrame"
|
||||
|
@ -1,20 +1,15 @@
|
||||
// src/components/iframe/check-balance-iframe/CheckBalanceIframe.tsx
|
||||
'use client'
|
||||
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
|
||||
import { Dialog } from '@workspace/ui/components/dialog'
|
||||
import { useEffect, useState } from 'react'
|
||||
import useCheckBalance from './useCheckBalance'
|
||||
|
||||
const CHECK_BALANCE_INTERVAL = 5000
|
||||
const IFRAME_ID = 'checkBalanceIframe'
|
||||
const BALANCE_IFRAME_ID = 'balance-check-iframe' // Different ID to avoid conflicts
|
||||
|
||||
/**
|
||||
* CheckBalanceIframe component that checks the balance using an iframe.
|
||||
* @param {Object} props - The component props.
|
||||
* @param {function} props.onBalanceChange - Callback function to be called when the balance changes.
|
||||
* @param {boolean} props.isPollingEnabled - Determines whether to poll the balance periodically.
|
||||
* @param {string} props.amount - The amount to check against the balance.
|
||||
* @returns {JSX.Element} - The CheckBalanceIframe component.
|
||||
* CheckBalanceIframe component for balance checking only.
|
||||
* This is separate from wallet connection functionality.
|
||||
*/
|
||||
const CheckBalanceIframe = ({
|
||||
onBalanceChange,
|
||||
@ -27,24 +22,18 @@ const CheckBalanceIframe = ({
|
||||
}) => {
|
||||
const { isBalanceSufficient, checkBalance } = useCheckBalance(
|
||||
amount,
|
||||
IFRAME_ID
|
||||
BALANCE_IFRAME_ID
|
||||
)
|
||||
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
|
||||
/**
|
||||
* useEffect hook that calls checkBalance when the component is loaded or the amount changes.
|
||||
*/
|
||||
// Check balance when loaded or amount changes
|
||||
useEffect(() => {
|
||||
if (!isLoaded) {
|
||||
return
|
||||
}
|
||||
if (!isLoaded) return
|
||||
checkBalance()
|
||||
}, [checkBalance, isLoaded])
|
||||
/**
|
||||
* useEffect hook that sets up an interval to poll the balance if polling is enabled.
|
||||
* Clears the interval when the component unmounts, balance is sufficient, or polling is disabled.
|
||||
*/
|
||||
|
||||
// Set up polling if enabled
|
||||
useEffect(() => {
|
||||
if (!isPollingEnabled || !isLoaded || isBalanceSufficient) {
|
||||
return
|
||||
@ -54,33 +43,33 @@ const CheckBalanceIframe = ({
|
||||
checkBalance()
|
||||
}, CHECK_BALANCE_INTERVAL)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
}
|
||||
return () => clearInterval(interval)
|
||||
}, [isBalanceSufficient, isPollingEnabled, checkBalance, isLoaded])
|
||||
|
||||
/**
|
||||
* useEffect hook that calls the onBalanceChange callback when the isBalanceSufficient state changes.
|
||||
*/
|
||||
// Notify parent of balance changes
|
||||
useEffect(() => {
|
||||
onBalanceChange(isBalanceSufficient)
|
||||
}, [isBalanceSufficient, onBalanceChange])
|
||||
|
||||
return (
|
||||
<Dialog open={false}>
|
||||
<VisuallyHidden>
|
||||
<iframe
|
||||
title="Check Balance"
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
id={IFRAME_ID}
|
||||
src={process.env.NEXT_PUBLIC_WALLET_IFRAME_URL}
|
||||
width="100%"
|
||||
height="100%"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
className="border rounded-md shadow-sm"
|
||||
/>
|
||||
</VisuallyHidden>
|
||||
</Dialog>
|
||||
<div style={{ display: 'none' }}>
|
||||
{/* This iframe is only for balance checking, not wallet connection */}
|
||||
<iframe
|
||||
title="Balance Checker"
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
id={BALANCE_IFRAME_ID}
|
||||
src={process.env.NEXT_PUBLIC_WALLET_IFRAME_URL}
|
||||
width="1"
|
||||
height="1"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1,23 +1,49 @@
|
||||
// src/components/onboarding/configure-step/configure-step.tsx
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { PlusCircle } from 'lucide-react'
|
||||
import { PlusCircle, Loader2, AlertTriangle, Info } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useOnboarding } from '@/components/onboarding/useOnboarding'
|
||||
import { useGQLClient } from '@/context'
|
||||
import { useWallet } from '@/context/WalletContext'
|
||||
import { Button } from '@workspace/ui/components/button'
|
||||
import { Input } from '@workspace/ui/components/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@workspace/ui/components/select'
|
||||
import { Checkbox } from '@workspace/ui/components/checkbox'
|
||||
import { Label } from '@workspace/ui/components/label'
|
||||
import { Alert, AlertDescription } from '@workspace/ui/components/alert'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card'
|
||||
import { Badge } from '@workspace/ui/components/badge'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface Deployer {
|
||||
deployerLrn: string
|
||||
deployerApiUrl: string
|
||||
minimumPayment?: string
|
||||
baseDomain: string
|
||||
}
|
||||
|
||||
interface Organization {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export function ConfigureStep() {
|
||||
const { nextStep, previousStep, setFormData, formData } = useOnboarding()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// Backend data
|
||||
const [deployers, setDeployers] = useState<Deployer[]>([])
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([])
|
||||
const [isLoadingDeployers, setIsLoadingDeployers] = useState(true)
|
||||
const [isLoadingOrgs, setIsLoadingOrgs] = useState(true)
|
||||
|
||||
// Form state
|
||||
const [deployOption, setDeployOption] = useState<'auction' | 'lrn'>(
|
||||
formData.deploymentType as ('auction' | 'lrn') || 'auction'
|
||||
formData.deploymentType as ('auction' | 'lrn') || 'lrn' // Default to LRN for simplicity
|
||||
)
|
||||
const [numberOfDeployers, setNumberOfDeployers] = useState<string>(
|
||||
formData.deployerCount || "1"
|
||||
@ -28,38 +54,115 @@ export function ConfigureStep() {
|
||||
const [selectedLrn, setSelectedLrn] = useState<string>(
|
||||
formData.selectedLrn || ""
|
||||
)
|
||||
const [selectedAccount, setSelectedAccount] = useState<string>("")
|
||||
const [environments, setEnvironments] = useState<{
|
||||
production: boolean,
|
||||
preview: boolean,
|
||||
development: boolean
|
||||
}>(formData.environments || {
|
||||
production: false,
|
||||
preview: false,
|
||||
development: false
|
||||
})
|
||||
const [envVars, setEnvVars] = useState<{ key: string; value: string }[]>([
|
||||
{ key: '', value: '' }
|
||||
const [selectedOrg, setSelectedOrg] = useState<string>(
|
||||
formData.selectedOrg || ""
|
||||
)
|
||||
const [envVars, setEnvVars] = useState<{ key: string; value: string; environments: string[] }[]>([
|
||||
{ key: '', value: '', environments: ['Production'] }
|
||||
])
|
||||
|
||||
// Contexts
|
||||
const gqlClient = useGQLClient()
|
||||
const { wallet } = useWallet()
|
||||
|
||||
// Handle hydration mismatch by waiting for mount
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Fetch deployers and organizations on mount
|
||||
useEffect(() => {
|
||||
if (mounted) {
|
||||
fetchDeployers()
|
||||
fetchOrganizations()
|
||||
}
|
||||
}, [mounted])
|
||||
|
||||
// Initialize environment variables from formData if available
|
||||
useEffect(() => {
|
||||
if (formData.environmentVariables) {
|
||||
const vars: { key: string; value: string }[] = Object.entries(formData.environmentVariables).map(
|
||||
([key, value]) => ({ key, value })
|
||||
)
|
||||
setEnvVars(vars.length > 0 ? vars : [{ key: '', value: '' }])
|
||||
if (formData.environmentVariables && Array.isArray(formData.environmentVariables)) {
|
||||
setEnvVars(formData.environmentVariables.length > 0 ? formData.environmentVariables : [
|
||||
{ key: '', value: '', environments: ['Production'] }
|
||||
])
|
||||
}
|
||||
}, [formData.environmentVariables])
|
||||
|
||||
// Fetch deployers from backend
|
||||
const fetchDeployers = async () => {
|
||||
try {
|
||||
setIsLoadingDeployers(true)
|
||||
const deployersData = await gqlClient.getDeployers()
|
||||
console.log('Available deployers:', deployersData)
|
||||
setDeployers(deployersData.deployers || [])
|
||||
|
||||
// Auto-select first deployer if available and none selected
|
||||
if (deployersData.deployers && deployersData.deployers.length > 0 && !selectedLrn) {
|
||||
setSelectedLrn(deployersData.deployers[0].deployerLrn)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching deployers:', error)
|
||||
toast.error('Failed to load deployers')
|
||||
} finally {
|
||||
setIsLoadingDeployers(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch organizations from backend
|
||||
const fetchOrganizations = async () => {
|
||||
try {
|
||||
setIsLoadingOrgs(true)
|
||||
const orgsData = await gqlClient.getOrganizations()
|
||||
console.log('Available organizations:', orgsData)
|
||||
setOrganizations(orgsData.organizations || [])
|
||||
|
||||
// Auto-select first organization if available and none selected
|
||||
if (orgsData.organizations && orgsData.organizations.length > 0 && !selectedOrg) {
|
||||
setSelectedOrg(orgsData.organizations[0].slug)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching organizations:', error)
|
||||
toast.error('Failed to load organizations')
|
||||
} finally {
|
||||
setIsLoadingOrgs(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Add an empty environment variable row
|
||||
const addEnvVar = () => {
|
||||
setEnvVars([...envVars, { key: '', value: '' }])
|
||||
setEnvVars([...envVars, { key: '', value: '', environments: ['Production'] }])
|
||||
}
|
||||
|
||||
// Remove environment variable row
|
||||
const removeEnvVar = (index: number) => {
|
||||
if (envVars.length > 1) {
|
||||
setEnvVars(envVars.filter((_, i) => i !== index))
|
||||
}
|
||||
}
|
||||
|
||||
// Update environment variable
|
||||
const updateEnvVar = (index: number, field: 'key' | 'value', value: string) => {
|
||||
const newEnvVars = [...envVars]
|
||||
newEnvVars[index][field] = value
|
||||
setEnvVars(newEnvVars)
|
||||
}
|
||||
|
||||
// Toggle environment for variable
|
||||
const toggleEnvironment = (index: number, environment: string) => {
|
||||
const newEnvVars = [...envVars]
|
||||
const currentEnvs = newEnvVars[index].environments
|
||||
|
||||
if (currentEnvs.includes(environment)) {
|
||||
newEnvVars[index].environments = currentEnvs.filter(env => env !== environment)
|
||||
} else {
|
||||
newEnvVars[index].environments = [...currentEnvs, environment]
|
||||
}
|
||||
|
||||
// Ensure at least one environment is selected
|
||||
if (newEnvVars[index].environments.length === 0) {
|
||||
newEnvVars[index].environments = ['Production']
|
||||
}
|
||||
|
||||
setEnvVars(newEnvVars)
|
||||
}
|
||||
|
||||
// Toggle deployment option
|
||||
@ -67,29 +170,36 @@ export function ConfigureStep() {
|
||||
setDeployOption(option)
|
||||
}
|
||||
|
||||
// Toggle environment checkbox
|
||||
const toggleEnvironment = (env: 'production' | 'preview' | 'development') => {
|
||||
setEnvironments({
|
||||
...environments,
|
||||
[env]: !environments[env]
|
||||
})
|
||||
// Get selected deployer details
|
||||
const selectedDeployer = deployers.find(d => d.deployerLrn === selectedLrn)
|
||||
|
||||
// Validate form
|
||||
const canProceed = () => {
|
||||
if (deployOption === 'lrn' && !selectedLrn) return false
|
||||
if (!selectedOrg) return false
|
||||
if (!wallet?.address) return false
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle next step
|
||||
const handleNext = () => {
|
||||
if (!canProceed()) {
|
||||
toast.error('Please complete all required fields')
|
||||
return
|
||||
}
|
||||
|
||||
// Filter out empty environment variables
|
||||
const validEnvVars = envVars.filter(env => env.key.trim() && env.value.trim())
|
||||
|
||||
// Save configuration to form data
|
||||
setFormData({
|
||||
deploymentType: deployOption,
|
||||
deployerCount: numberOfDeployers,
|
||||
maxPrice: maxPrice,
|
||||
selectedLrn: selectedLrn,
|
||||
environments: environments,
|
||||
environmentVariables: envVars.reduce((acc, { key, value }) => {
|
||||
if (key && value) {
|
||||
acc[key] = value
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
selectedOrg: selectedOrg,
|
||||
paymentAddress: wallet?.address,
|
||||
environmentVariables: validEnvVars
|
||||
})
|
||||
|
||||
nextStep()
|
||||
@ -103,6 +213,10 @@ export function ConfigureStep() {
|
||||
// Determine if dark mode is active
|
||||
const isDarkMode = resolvedTheme === 'dark'
|
||||
|
||||
// Get deployment mode info
|
||||
const isTemplateMode = formData.deploymentMode === 'template'
|
||||
const selectedItem = isTemplateMode ? formData.template?.name : formData.githubRepo
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col p-8 overflow-y-auto">
|
||||
{/* Configure icon and header */}
|
||||
@ -115,119 +229,250 @@ export function ConfigureStep() {
|
||||
</div>
|
||||
<h2 className={`text-2xl font-medium text-center mb-2 ${isDarkMode ? "text-white" : "text-zinc-900"}`}>Configure</h2>
|
||||
<p className={`text-center text-zinc-500 max-w-md`}>
|
||||
Set the deployer LRN for a single deployment or by creating a deployer auction for multiple deployments
|
||||
Define the deployment type
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-xl mx-auto w-full">
|
||||
{/* Deployment options */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-6">
|
||||
<Button
|
||||
variant={deployOption === 'auction' ? "default" : "outline"}
|
||||
className={`py-3 ${deployOption === 'auction'
|
||||
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white')
|
||||
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`}
|
||||
onClick={() => toggleDeployOption('auction')}
|
||||
>
|
||||
Create Auction
|
||||
</Button>
|
||||
<Button
|
||||
variant={deployOption === 'lrn' ? "default" : "outline"}
|
||||
className={`py-3 ${deployOption === 'lrn'
|
||||
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white')
|
||||
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`}
|
||||
onClick={() => toggleDeployOption('lrn')}
|
||||
>
|
||||
Deployer LRN
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{deployOption === 'auction' ? (
|
||||
<>
|
||||
{/* Auction settings */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<Label htmlFor="deployers" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
|
||||
Number of Deployers
|
||||
</Label>
|
||||
<Select value={numberOfDeployers} onValueChange={setNumberOfDeployers}>
|
||||
<SelectTrigger id="deployers" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
|
||||
<SelectValue placeholder="Select number" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1</SelectItem>
|
||||
<SelectItem value="2">2</SelectItem>
|
||||
<SelectItem value="3">3</SelectItem>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* Project Summary */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Info className="h-4 w-4" />
|
||||
Project Summary
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Type:</span>
|
||||
<Badge variant="secondary">{isTemplateMode ? 'Template' : 'Repository'}</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="maxPrice" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
|
||||
Maximum Price (aint)
|
||||
</Label>
|
||||
<Select value={maxPrice} onValueChange={setMaxPrice}>
|
||||
<SelectTrigger id="maxPrice" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
|
||||
<SelectValue placeholder="Select price" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
<SelectItem value="1000">1000</SelectItem>
|
||||
<SelectItem value="2000">2000</SelectItem>
|
||||
<SelectItem value="5000">5000</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Source:</span>
|
||||
<span className="font-mono text-xs">{selectedItem}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Project Name:</span>
|
||||
<span>{formData.projectName}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Organization Selection */}
|
||||
<div className="mb-6">
|
||||
<Label htmlFor="organization" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
|
||||
Organization *
|
||||
</Label>
|
||||
{isLoadingOrgs ? (
|
||||
<div className="flex items-center justify-center p-3 border rounded-md">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
<span className="text-sm text-muted-foreground">Loading organizations...</span>
|
||||
</div>
|
||||
) : organizations.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
No organizations found. You need to be part of at least one organization.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Select value={selectedOrg} onValueChange={setSelectedOrg}>
|
||||
<SelectTrigger id="organization" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
|
||||
<SelectValue placeholder="Select organization" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{organizations.map((org) => (
|
||||
<SelectItem key={org.id} value={org.slug}>
|
||||
{org.name} ({org.slug})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Deployment options */}
|
||||
<div className="mb-6">
|
||||
<Label className={`text-sm mb-3 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
|
||||
Deployment Type
|
||||
</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant={deployOption === 'lrn' ? "default" : "outline"}
|
||||
className={`py-3 ${deployOption === 'lrn'
|
||||
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white')
|
||||
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`}
|
||||
onClick={() => toggleDeployOption('lrn')}
|
||||
>
|
||||
Deployer LRN
|
||||
</Button>
|
||||
<Button
|
||||
variant={deployOption === 'auction' ? "default" : "outline"}
|
||||
className={`py-3 ${deployOption === 'auction'
|
||||
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white')
|
||||
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`}
|
||||
onClick={() => toggleDeployOption('auction')}
|
||||
>
|
||||
Create Auction
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deployOption === 'lrn' ? (
|
||||
/* LRN Deployment Settings */
|
||||
<div className="mb-6">
|
||||
<Label htmlFor="lrn" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
|
||||
Select Deployer LRN *
|
||||
</Label>
|
||||
{isLoadingDeployers ? (
|
||||
<div className="flex items-center justify-center p-3 border rounded-md">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
<span className="text-sm text-muted-foreground">Loading deployers...</span>
|
||||
</div>
|
||||
) : deployers.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
No deployers available. Please contact support.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Select value={selectedLrn} onValueChange={setSelectedLrn}>
|
||||
<SelectTrigger id="lrn" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
|
||||
<SelectValue placeholder="Select a deployer" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{deployers.map((deployer) => (
|
||||
<SelectItem key={deployer.deployerLrn} value={deployer.deployerLrn}>
|
||||
<div className="flex flex-col">
|
||||
<span>{deployer.deployerLrn}</span>
|
||||
{deployer.minimumPayment && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Min payment: {deployer.minimumPayment}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Deployer Details */}
|
||||
{selectedDeployer && (
|
||||
<div className="mt-3 p-3 bg-muted rounded-md">
|
||||
<div className="text-sm space-y-1">
|
||||
<div><strong>API URL:</strong> {selectedDeployer.deployerApiUrl}</div>
|
||||
<div><strong>Base Domain:</strong> {selectedDeployer.baseDomain}</div>
|
||||
{selectedDeployer.minimumPayment && (
|
||||
<div><strong>Minimum Payment:</strong> {selectedDeployer.minimumPayment}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* LRN settings */}
|
||||
<div className="mb-6">
|
||||
<Label htmlFor="lrn" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
|
||||
Select Deployer LRN
|
||||
/* Auction Settings */
|
||||
<div className="grid grid-cols-2 gap-4 mb-6">
|
||||
<div>
|
||||
<Label htmlFor="deployers" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
|
||||
Number of Deployers
|
||||
</Label>
|
||||
<Select value={selectedLrn} onValueChange={setSelectedLrn}>
|
||||
<SelectTrigger id="lrn" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
|
||||
<SelectValue placeholder="Select" />
|
||||
<Select value={numberOfDeployers} onValueChange={setNumberOfDeployers}>
|
||||
<SelectTrigger id="deployers" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
|
||||
<SelectValue placeholder="Select number" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="lrn-1">Deployer LRN 1</SelectItem>
|
||||
<SelectItem value="lrn-2">Deployer LRN 2</SelectItem>
|
||||
<SelectItem value="lrn-3">Deployer LRN 3</SelectItem>
|
||||
<SelectItem value="1">1</SelectItem>
|
||||
<SelectItem value="2">2</SelectItem>
|
||||
<SelectItem value="3">3</SelectItem>
|
||||
<SelectItem value="5">5</SelectItem>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
<div>
|
||||
<Label htmlFor="maxPrice" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
|
||||
Maximum Price (aint)
|
||||
</Label>
|
||||
<Select value={maxPrice} onValueChange={setMaxPrice}>
|
||||
<SelectTrigger id="maxPrice" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
|
||||
<SelectValue placeholder="Select price" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
<SelectItem value="1000">1000</SelectItem>
|
||||
<SelectItem value="2000">2000</SelectItem>
|
||||
<SelectItem value="5000">5000</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Address */}
|
||||
<div className="mb-6">
|
||||
<Label className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
|
||||
Payment Address
|
||||
</Label>
|
||||
<div className={`p-3 border rounded-md bg-muted ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}>
|
||||
<div className="text-sm font-mono break-all">
|
||||
{wallet?.address || 'No wallet connected'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<div className="mb-6">
|
||||
<Label className={`text-sm font-medium mb-2 block ${isDarkMode ? 'text-white' : 'text-zinc-900'}`}>Environment Variables</Label>
|
||||
<Label className={`text-sm font-medium mb-2 block ${isDarkMode ? 'text-white' : 'text-zinc-900'}`}>
|
||||
Environment Variables
|
||||
</Label>
|
||||
<div className={`border rounded-md p-4 ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}>
|
||||
{envVars.map((envVar, index) => (
|
||||
<div key={index} className="grid grid-cols-2 gap-2 mb-2">
|
||||
<Input
|
||||
placeholder="KEY"
|
||||
value={envVar.key}
|
||||
onChange={(e) => {
|
||||
const newEnvVars = [...envVars];
|
||||
newEnvVars[index].key = e.target.value;
|
||||
setEnvVars(newEnvVars);
|
||||
}}
|
||||
className={`bg-transparent ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}
|
||||
/>
|
||||
<Input
|
||||
placeholder="VALUE"
|
||||
value={envVar.value}
|
||||
onChange={(e) => {
|
||||
const newEnvVars = [...envVars];
|
||||
newEnvVars[index].value = e.target.value;
|
||||
setEnvVars(newEnvVars);
|
||||
}}
|
||||
className={`bg-transparent ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}
|
||||
/>
|
||||
<div key={index} className="space-y-2 mb-4 pb-4 border-b border-muted last:border-b-0 last:mb-0 last:pb-0">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
placeholder="KEY"
|
||||
value={envVar.key}
|
||||
onChange={(e) => updateEnvVar(index, 'key', e.target.value)}
|
||||
className={`bg-transparent ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}
|
||||
/>
|
||||
<Input
|
||||
placeholder="VALUE"
|
||||
value={envVar.value}
|
||||
onChange={(e) => updateEnvVar(index, 'value', e.target.value)}
|
||||
className={`bg-transparent ${isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-xs text-muted-foreground">Environments:</span>
|
||||
{['Production', 'Preview', 'Development'].map((env) => (
|
||||
<div key={env} className="flex items-center gap-1">
|
||||
<Checkbox
|
||||
id={`${index}-${env}`}
|
||||
checked={envVar.environments.includes(env)}
|
||||
onCheckedChange={() => toggleEnvironment(index, env)}
|
||||
/>
|
||||
<Label htmlFor={`${index}-${env}`} className="text-xs">
|
||||
{env}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
{envVars.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeEnvVar(index)}
|
||||
className="ml-auto text-red-500 hover:text-red-700 h-6 px-2"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
@ -241,62 +486,6 @@ export function ConfigureStep() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Environment Tags */}
|
||||
<div className="mb-6">
|
||||
<Label className={`text-sm font-medium mb-2 block ${isDarkMode ? 'text-white' : 'text-zinc-900'}`}>Environment</Label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="production"
|
||||
checked={environments.production}
|
||||
onCheckedChange={() => toggleEnvironment('production')}
|
||||
className={isDarkMode ? 'border-zinc-600' : 'border-zinc-300'}
|
||||
/>
|
||||
<Label htmlFor="production" className={`ml-2 ${isDarkMode ? 'text-zinc-400' : 'text-zinc-600'}`}>
|
||||
Production
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="preview"
|
||||
checked={environments.preview}
|
||||
onCheckedChange={() => toggleEnvironment('preview')}
|
||||
className={isDarkMode ? 'border-zinc-600' : 'border-zinc-300'}
|
||||
/>
|
||||
<Label htmlFor="preview" className={`ml-2 ${isDarkMode ? 'text-zinc-400' : 'text-zinc-600'}`}>
|
||||
Preview
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="development"
|
||||
checked={environments.development}
|
||||
onCheckedChange={() => toggleEnvironment('development')}
|
||||
className={isDarkMode ? 'border-zinc-600' : 'border-zinc-300'}
|
||||
/>
|
||||
<Label htmlFor="development" className={`ml-2 ${isDarkMode ? 'text-zinc-400' : 'text-zinc-600'}`}>
|
||||
Development
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account selection */}
|
||||
<div className="mb-8">
|
||||
<Label htmlFor="account" className={`text-sm mb-2 block ${isDarkMode ? 'text-zinc-400' : 'text-zinc-700'}`}>
|
||||
Select Account
|
||||
</Label>
|
||||
<Select value={selectedAccount} onValueChange={setSelectedAccount}>
|
||||
<SelectTrigger id="account" className={isDarkMode ? 'border-zinc-700' : 'border-zinc-300'}>
|
||||
<SelectValue placeholder="Select" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="account-1">Account 1</SelectItem>
|
||||
<SelectItem value="account-2">Account 2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<Button
|
||||
@ -310,6 +499,7 @@ export function ConfigureStep() {
|
||||
variant="default"
|
||||
className={`${isDarkMode ? 'bg-zinc-800' : 'bg-zinc-800'} text-white hover:bg-zinc-700`}
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
|
@ -1,169 +1,449 @@
|
||||
// src/components/onboarding/connect-step/connect-step.tsx
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Github } from 'lucide-react'
|
||||
import { Github, Wallet, CheckCircle2, AlertTriangle, Loader2, ExternalLink, ChevronDown } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { SignIn } from '@clerk/nextjs'
|
||||
import { useOnboarding } from '@/components/onboarding/useOnboarding'
|
||||
import { Button } from '@workspace/ui/components/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@workspace/ui/components/select'
|
||||
import { useAuthStatus } from '@/hooks/useAuthStatus'
|
||||
import { useRepoData } from '@/hooks/useRepoData'
|
||||
import { Button } from '@workspace/ui/components/button'
|
||||
import { Card, CardContent } from '@workspace/ui/components/card'
|
||||
import { Input } from '@workspace/ui/components/input'
|
||||
import { Label } from '@workspace/ui/components/label'
|
||||
import { Alert, AlertDescription } from '@workspace/ui/components/alert'
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@workspace/ui/components/collapsible'
|
||||
import { toast } from 'sonner'
|
||||
import { GitHubBackendAuth } from '@/components/GitHubBackendAuth'
|
||||
import { AVAILABLE_TEMPLATES, type TemplateDetail } from '@/constants/templates'
|
||||
|
||||
interface Repository {
|
||||
id: string | number
|
||||
full_name: string
|
||||
html_url?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function ConnectStep() {
|
||||
const { nextStep, setFormData, formData } = useOnboarding()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// Repository vs Template selection
|
||||
const [selectedRepo, setSelectedRepo] = useState<string>(formData.githubRepo || '')
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<TemplateDetail | undefined>(
|
||||
formData.template || undefined
|
||||
)
|
||||
const [projectName, setProjectName] = useState<string>(formData.projectName || '')
|
||||
const [isImportMode, setIsImportMode] = useState(true)
|
||||
const { repoData: repositories, isLoading } = useRepoData('')
|
||||
|
||||
// Auth status and warning display
|
||||
const [showAuthWarning, setShowAuthWarning] = useState(false)
|
||||
|
||||
// Auth status hook
|
||||
const {
|
||||
clerk,
|
||||
wallet,
|
||||
backend,
|
||||
isFullyAuthenticated,
|
||||
isReady,
|
||||
missing,
|
||||
progress,
|
||||
connectWallet,
|
||||
checkGithubBackendAuth
|
||||
} = useAuthStatus()
|
||||
|
||||
// Repository data
|
||||
const { repoData: repositories, isLoading: isLoadingRepos } = useRepoData('')
|
||||
|
||||
// Handle hydration mismatch by waiting for mount
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Auto-hide auth warning when fully authenticated
|
||||
useEffect(() => {
|
||||
if (isFullyAuthenticated) {
|
||||
setShowAuthWarning(false)
|
||||
}
|
||||
}, [isFullyAuthenticated])
|
||||
|
||||
// Handle repository selection
|
||||
const handleRepoSelect = (repo: string) => {
|
||||
setSelectedRepo(repo)
|
||||
setFormData({ githubRepo: repo })
|
||||
setSelectedTemplate(undefined)
|
||||
setFormData({
|
||||
githubRepo: repo,
|
||||
template: undefined,
|
||||
deploymentMode: 'repository',
|
||||
projectName
|
||||
})
|
||||
}
|
||||
|
||||
// Handle template selection
|
||||
const handleTemplateSelect = (template: TemplateDetail) => {
|
||||
setSelectedTemplate(template)
|
||||
setSelectedRepo('')
|
||||
// Auto-fill project name if empty
|
||||
if (!projectName) {
|
||||
const suggestedName = `my-${template.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`
|
||||
setProjectName(suggestedName)
|
||||
}
|
||||
setFormData({
|
||||
template: template,
|
||||
githubRepo: '',
|
||||
deploymentMode: 'template',
|
||||
projectName: projectName || `my-${template.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}`
|
||||
})
|
||||
}
|
||||
|
||||
// Handle mode toggle between import and template
|
||||
const toggleMode = (mode: 'import' | 'template') => {
|
||||
setIsImportMode(mode === 'import')
|
||||
// Clear selections when switching modes
|
||||
if (mode === 'import') {
|
||||
setSelectedTemplate(undefined)
|
||||
setFormData({
|
||||
template: undefined,
|
||||
deploymentMode: 'repository',
|
||||
projectName
|
||||
})
|
||||
} else {
|
||||
setSelectedRepo('')
|
||||
setFormData({
|
||||
githubRepo: '',
|
||||
deploymentMode: 'template',
|
||||
projectName
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle project name change
|
||||
const handleProjectNameChange = (value: string) => {
|
||||
setProjectName(value)
|
||||
setFormData({ projectName: value })
|
||||
}
|
||||
|
||||
// Handle wallet connection
|
||||
const handleConnectWallet = async () => {
|
||||
try {
|
||||
await connectWallet()
|
||||
toast.success('Wallet connected successfully')
|
||||
} catch (error) {
|
||||
console.error('Wallet connection failed:', error)
|
||||
toast.error('Failed to connect wallet')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle GitHub backend auth status change
|
||||
const handleGithubAuthChange = async (isAuthenticated: boolean) => {
|
||||
await checkGithubBackendAuth()
|
||||
if (isAuthenticated) {
|
||||
toast.success('GitHub backend authentication completed!')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle next step
|
||||
const handleNext = () => {
|
||||
if (selectedRepo || !isImportMode) {
|
||||
nextStep()
|
||||
if (!isFullyAuthenticated) {
|
||||
toast.error('Please complete all authentication steps first')
|
||||
setShowAuthWarning(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (isImportMode && !selectedRepo) {
|
||||
toast.error('Please select a repository to continue')
|
||||
return
|
||||
}
|
||||
|
||||
if (!isImportMode && (!selectedTemplate || !projectName.trim())) {
|
||||
toast.error('Please select a template and enter a project name')
|
||||
return
|
||||
}
|
||||
|
||||
// For repository import, project name is optional but we'll use repo name as fallback
|
||||
const finalProjectName = projectName.trim() || (isImportMode ? selectedRepo.split('/')[1] : '')
|
||||
|
||||
// Set final form data and proceed
|
||||
setFormData({
|
||||
deploymentMode: isImportMode ? 'repository' : 'template',
|
||||
githubRepo: isImportMode ? selectedRepo : '',
|
||||
template: !isImportMode ? selectedTemplate : undefined,
|
||||
projectName: finalProjectName
|
||||
})
|
||||
|
||||
nextStep()
|
||||
}
|
||||
|
||||
// Don't render UI until after mount to prevent hydration mismatch
|
||||
if (!mounted) {
|
||||
return null
|
||||
if (!mounted || !isReady) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
|
||||
<p className="text-sm text-zinc-500">Loading authentication status...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Determine if dark mode is active
|
||||
const isDarkMode = resolvedTheme === 'dark'
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center p-8">
|
||||
<div className="max-w-md w-full mx-auto">
|
||||
{/* Connect icon */}
|
||||
<div className="mx-auto mb-6 flex justify-center">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className={isDarkMode ? "text-white" : "text-black"}>
|
||||
<path d="M15 7h3a5 5 0 0 1 5 5 5 5 0 0 1-5 5h-3m-6 0H6a5 5 0 0 1-5-5 5 5 0 0 1 5-5h3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="w-full h-full flex flex-col p-8 overflow-y-auto">
|
||||
<div className="max-w-2xl w-full mx-auto">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h2 className={`text-2xl font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} mb-2`}>
|
||||
Connect
|
||||
</h2>
|
||||
<p className="text-zinc-500 mb-6">
|
||||
Connect and import a GitHub repo or start from a template
|
||||
</p>
|
||||
|
||||
{/* Connect header */}
|
||||
<h2 className={`text-2xl font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} text-center mb-2`}>Connect</h2>
|
||||
<p className="text-center text-zinc-500 mb-8">
|
||||
Connect and import a GitHub repo or start from a template
|
||||
</p>
|
||||
|
||||
{/* Git account selector */}
|
||||
<div className="mb-4">
|
||||
<Select defaultValue="git-account">
|
||||
<SelectTrigger className={`w-full py-3 ${isDarkMode ? "border-zinc-700" : "border-zinc-300"}`}>
|
||||
<div className="flex items-center">
|
||||
<Github className="mr-2 h-5 w-5" />
|
||||
<SelectValue placeholder="Select Git account" />
|
||||
{/* GitHub Account Selector - Only show if multiple accounts */}
|
||||
{clerk.user?.externalAccounts && clerk.user.externalAccounts.length > 1 && (
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-zinc-100 dark:bg-zinc-800 rounded-md cursor-pointer hover:bg-zinc-200 dark:hover:bg-zinc-700">
|
||||
<Github className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{clerk.user?.externalAccounts?.find(acc => acc.provider === 'github')?.username || 'git-account'}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="git-account">git-account</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mode buttons */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||
{/* Authentication Warning - Only show if not fully authenticated */}
|
||||
{!isFullyAuthenticated && (
|
||||
<Collapsible open={showAuthWarning} onOpenChange={setShowAuthWarning}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Alert className="mb-6 cursor-pointer hover:bg-amber-50 dark:hover:bg-amber-950/20">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription className="flex items-center justify-between w-full">
|
||||
<span>Authentication required to continue ({progress.completed}/{progress.total} complete)</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 mb-6">
|
||||
{/* Authentication steps - same as before but in collapsible */}
|
||||
{missing.clerkSignIn && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Github className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Sign in with Clerk</span>
|
||||
</div>
|
||||
<div className="scale-90 origin-top-left">
|
||||
<SignIn routing="hash" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{missing.clerkGithub && !missing.clerkSignIn && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Github className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Connect GitHub Account</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => window.open('/user-profile', '_blank')}>
|
||||
<ExternalLink className="h-3 w-3 mr-2" />
|
||||
Connect GitHub
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{missing.walletConnection && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Wallet className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Connect Wallet</span>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleConnectWallet}>
|
||||
Connect Wallet
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{missing.githubBackendSync && !missing.walletConnection && !missing.clerkGithub && (
|
||||
<Card className="border-amber-200 bg-amber-50/50 dark:bg-amber-950/20">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Github className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Sync GitHub Access</span>
|
||||
</div>
|
||||
<GitHubBackendAuth onAuthStatusChange={handleGithubAuthChange} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
{/* Mode Selection Tabs */}
|
||||
<div className="grid grid-cols-2 gap-1 p-1 bg-zinc-100 dark:bg-zinc-800 rounded-lg mb-6">
|
||||
<Button
|
||||
variant={isImportMode ? "default" : "outline"}
|
||||
className={`py-3 ${isImportMode
|
||||
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white')
|
||||
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`}
|
||||
variant={isImportMode ? "default" : "ghost"}
|
||||
className={`${isImportMode
|
||||
? 'bg-white dark:bg-zinc-700 shadow-sm'
|
||||
: 'bg-transparent hover:bg-white/50 dark:hover:bg-zinc-700/50'
|
||||
}`}
|
||||
onClick={() => toggleMode('import')}
|
||||
>
|
||||
Import a repository
|
||||
</Button>
|
||||
<Button
|
||||
variant={!isImportMode ? "default" : "outline"}
|
||||
className={`py-3 ${!isImportMode
|
||||
? (isDarkMode ? 'bg-zinc-800 text-white' : 'bg-zinc-800 text-white')
|
||||
: (isDarkMode ? 'bg-transparent border-zinc-700 text-zinc-400' : 'bg-transparent border-zinc-300 text-zinc-600')}`}
|
||||
variant={!isImportMode ? "default" : "ghost"}
|
||||
className={`${!isImportMode
|
||||
? 'bg-white dark:bg-zinc-700 shadow-sm'
|
||||
: 'bg-transparent hover:bg-white/50 dark:hover:bg-zinc-700/50'
|
||||
}`}
|
||||
onClick={() => toggleMode('template')}
|
||||
>
|
||||
Start with a template
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Repository or template list */}
|
||||
{/* Content Area */}
|
||||
{isImportMode ? (
|
||||
<div className={`border rounded-md overflow-hidden mb-4 ${isDarkMode ? "border-zinc-700" : "border-zinc-300"}`}>
|
||||
{isLoading ? (
|
||||
<div className="p-6 text-center text-zinc-500">
|
||||
/* Repository Selection */
|
||||
<div className="space-y-4">
|
||||
{isLoadingRepos ? (
|
||||
<div className="p-8 text-center text-zinc-500">
|
||||
<div className="animate-spin h-5 w-5 border-2 border-zinc-500 border-t-transparent rounded-full mx-auto mb-2"></div>
|
||||
Loading repositories...
|
||||
</div>
|
||||
) : !repositories || repositories.length === 0 ? (
|
||||
<div className="p-6 text-center text-zinc-500">
|
||||
No repositories found
|
||||
<div className="p-8 text-center text-zinc-500">
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
No repositories found. Make sure your GitHub account has repositories.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{repositories.map((repo: Repository) => (
|
||||
<div
|
||||
key={repo.id}
|
||||
className={`flex items-center p-4 ${isDarkMode ? "border-zinc-700" : "border-zinc-200"} border-b last:border-b-0 cursor-pointer ${
|
||||
selectedRepo === repo.full_name
|
||||
? (isDarkMode ? 'bg-zinc-800' : 'bg-zinc-100')
|
||||
: (isDarkMode ? 'hover:bg-zinc-800' : 'hover:bg-zinc-50')
|
||||
}`}
|
||||
onClick={() => handleRepoSelect(repo.full_name)}
|
||||
>
|
||||
<div className={`flex-1 ${isDarkMode ? "text-white" : "text-zinc-900"}`}>
|
||||
<Github className="inline-block h-4 w-4 mr-2 text-zinc-500" />
|
||||
<span>{repo.full_name}</span>
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500">
|
||||
5 minutes ago
|
||||
<>
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{repositories.map((repo: Repository) => (
|
||||
<div
|
||||
key={repo.id}
|
||||
className={`flex items-center p-4 rounded-lg border cursor-pointer transition-all ${
|
||||
selectedRepo === repo.full_name
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-950/20'
|
||||
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600'
|
||||
}`}
|
||||
onClick={() => handleRepoSelect(repo.full_name)}
|
||||
>
|
||||
<Github className="h-5 w-5 mr-3 text-zinc-500 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{repo.full_name}</div>
|
||||
{repo.description && (
|
||||
<div className="text-xs text-zinc-500 truncate">{repo.description}</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedRepo === repo.full_name && (
|
||||
<CheckCircle2 className="h-5 w-5 text-blue-500 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Project Name Input for Repository Import */}
|
||||
{selectedRepo && (
|
||||
<div className="mt-6 space-y-2">
|
||||
<Label htmlFor="projectName" className="text-sm font-medium">
|
||||
Project Name
|
||||
</Label>
|
||||
<Input
|
||||
id="projectName"
|
||||
value={projectName}
|
||||
onChange={(e) => handleProjectNameChange(e.target.value)}
|
||||
placeholder="my-project-name"
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">
|
||||
This will be the name of your deployment project
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`border rounded-md overflow-hidden mb-4 ${isDarkMode ? "border-zinc-700" : "border-zinc-300"}`}>
|
||||
<div className="p-6 text-center text-zinc-500">
|
||||
Template selection coming soon
|
||||
</div>
|
||||
/* Template Selection */
|
||||
<div className="space-y-4">
|
||||
{AVAILABLE_TEMPLATES.filter(t => !t.isComingSoon).map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className={`flex items-center p-4 rounded-lg border cursor-pointer transition-all ${
|
||||
selectedTemplate?.id === template.id
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-950/20'
|
||||
: 'border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600'
|
||||
}`}
|
||||
onClick={() => handleTemplateSelect(template)}
|
||||
>
|
||||
{/* Template Icon */}
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-zinc-100 dark:bg-zinc-800 mr-4">
|
||||
<div className="w-6 h-6 bg-zinc-600 dark:bg-zinc-400 rounded flex items-center justify-center text-xs font-bold text-white">
|
||||
{template.icon === 'web' ? 'PWA' : template.icon === 'nextjs' ? 'N' : 'IMG'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Template Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm mb-1">{template.name}</div>
|
||||
<div className="flex items-center text-xs text-zinc-500">
|
||||
<Github className="h-3 w-3 mr-1" />
|
||||
{template.repoFullName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selection Indicator */}
|
||||
{selectedTemplate?.id === template.id && (
|
||||
<CheckCircle2 className="h-5 w-5 text-blue-500 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Project Name Input for Templates */}
|
||||
{selectedTemplate && (
|
||||
<div className="mt-6 space-y-2">
|
||||
<Label htmlFor="projectName" className="text-sm font-medium">
|
||||
Project Name
|
||||
</Label>
|
||||
<Input
|
||||
id="projectName"
|
||||
value={projectName}
|
||||
onChange={(e) => handleProjectNameChange(e.target.value)}
|
||||
placeholder="new-repository-name"
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">
|
||||
This will be the name of your new GitHub repository
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between items-center mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`${isDarkMode ? "text-zinc-400 border-zinc-700" : "text-zinc-600 border-zinc-300"} bg-transparent`}
|
||||
disabled={true}
|
||||
>
|
||||
<Button variant="outline" disabled>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
className={`${isDarkMode ? 'bg-zinc-800' : 'bg-zinc-800'} text-white hover:bg-zinc-700`}
|
||||
onClick={handleNext}
|
||||
disabled={!selectedRepo && isImportMode}
|
||||
disabled={!isFullyAuthenticated || (isImportMode ? !selectedRepo : (!selectedTemplate || !projectName.trim()))}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
|
@ -1,12 +1,21 @@
|
||||
// src/components/onboarding/deploy-step/deploy-step.tsx
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Github, Loader2 } from 'lucide-react'
|
||||
import { Github, Loader2, AlertTriangle, CheckCircle2 } from 'lucide-react'
|
||||
import { useOnboarding } from '@/components/onboarding/useOnboarding'
|
||||
import { useGQLClient } from '@/context'
|
||||
import { useWallet } from '@/context/WalletContext'
|
||||
import { useDeployment } from '@/hooks/useDeployment'
|
||||
import { useTemplateDeployment } from '@/hooks/useTemplate'
|
||||
import { Button } from '@workspace/ui/components/button'
|
||||
import { Progress } from '@workspace/ui/components/progress'
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogFooter } from '@workspace/ui/components/dialog'
|
||||
import { Alert, AlertDescription } from '@workspace/ui/components/alert'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@workspace/ui/components/card'
|
||||
import { Badge } from '@workspace/ui/components/badge'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function DeployStep() {
|
||||
const { previousStep, nextStep, formData, setFormData } = useOnboarding()
|
||||
@ -14,21 +23,67 @@ export function DeployStep() {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// State
|
||||
const [isDeploying, setIsDeploying] = useState(false)
|
||||
const [deploymentProgress, setDeploymentProgress] = useState(0)
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
|
||||
const [deploymentError, setDeploymentError] = useState<string | null>(null)
|
||||
const [deploymentSuccess, setDeploymentSuccess] = useState(false)
|
||||
|
||||
// Contexts and hooks
|
||||
const { wallet } = useWallet()
|
||||
const { deployRepository, isDeploying: isRepoDeploying } = useDeployment()
|
||||
const { deployTemplate, isDeploying: isTemplateDeploying } = useTemplateDeployment()
|
||||
|
||||
// Determine deployment type and get the right deploying state
|
||||
const isTemplateMode = formData.deploymentMode === 'template'
|
||||
const isDeploying = isTemplateMode ? isTemplateDeploying : isRepoDeploying
|
||||
|
||||
// Handle hydration mismatch by waiting for mount
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
// Repository information from previous steps
|
||||
const repoFullName = formData.githubRepo || 'git-account/repo-name'
|
||||
const branch = 'main'
|
||||
// Get deployment info
|
||||
const getDeploymentInfo = () => {
|
||||
if (isTemplateMode) {
|
||||
return {
|
||||
name: formData.template?.name || 'Template Project',
|
||||
source: formData.template?.repoFullName || 'Unknown Template',
|
||||
projectName: formData.projectName || 'New Project',
|
||||
type: 'Template'
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
name: formData.githubRepo?.split('/').pop() || 'Repository',
|
||||
source: formData.githubRepo || 'Unknown Repository',
|
||||
projectName: formData.projectName || formData.githubRepo?.split('/').pop() || 'New Project',
|
||||
type: 'Repository'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deploymentInfo = getDeploymentInfo()
|
||||
|
||||
// Open the confirmation modal
|
||||
const handlePayAndDeploy = () => {
|
||||
if (!wallet?.address) {
|
||||
toast.error('Wallet not connected')
|
||||
return
|
||||
}
|
||||
|
||||
if (!formData.selectedOrg) {
|
||||
toast.error('No organization selected')
|
||||
return
|
||||
}
|
||||
|
||||
if (isTemplateMode && (!formData.template || !formData.projectName)) {
|
||||
toast.error('Template or project name missing')
|
||||
return
|
||||
}
|
||||
|
||||
if (!isTemplateMode && !formData.githubRepo) {
|
||||
toast.error('Repository not selected')
|
||||
return
|
||||
}
|
||||
|
||||
setShowConfirmDialog(true)
|
||||
}
|
||||
|
||||
@ -38,42 +93,91 @@ export function DeployStep() {
|
||||
}
|
||||
|
||||
// Handle confirmed deployment
|
||||
const handleConfirmDeploy = () => {
|
||||
const handleConfirmDeploy = async () => {
|
||||
setShowConfirmDialog(false)
|
||||
startDeployment()
|
||||
setDeploymentError(null)
|
||||
setDeploymentSuccess(false)
|
||||
|
||||
try {
|
||||
if (isTemplateMode) {
|
||||
await deployTemplateProject()
|
||||
} else {
|
||||
await deployRepositoryProject()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Deployment failed:', error)
|
||||
setDeploymentError(error instanceof Error ? error.message : 'Deployment failed')
|
||||
}
|
||||
}
|
||||
|
||||
// Start the deployment process
|
||||
const startDeployment = () => {
|
||||
setIsDeploying(true)
|
||||
// Deploy template project
|
||||
const deployTemplateProject = async () => {
|
||||
if (!formData.template || !formData.projectName || !formData.selectedOrg) {
|
||||
throw new Error('Missing required template deployment data')
|
||||
}
|
||||
|
||||
// Simulate deployment process with progress updates
|
||||
let progress = 0
|
||||
const interval = setInterval(() => {
|
||||
progress += 10
|
||||
setDeploymentProgress(progress)
|
||||
const config = {
|
||||
template: formData.template,
|
||||
projectName: formData.projectName,
|
||||
organizationSlug: formData.selectedOrg,
|
||||
environmentVariables: formData.environmentVariables || [],
|
||||
deployerLrn: formData.selectedLrn
|
||||
}
|
||||
|
||||
if (progress >= 100) {
|
||||
clearInterval(interval)
|
||||
console.log('Deploying template with config:', config)
|
||||
|
||||
// Generate deployment ID and create URL
|
||||
const deploymentId = `deploy-${Math.random().toString(36).substring(2, 9)}`
|
||||
const repoName = repoFullName.split('/').pop() || 'app'
|
||||
const projectId = `proj-${Math.random().toString(36).substring(2, 9)}`
|
||||
const result = await deployTemplate(config)
|
||||
|
||||
// Save deployment info
|
||||
setFormData({
|
||||
deploymentId,
|
||||
deploymentUrl: `https://${repoName}.laconic.deploy`,
|
||||
projectId
|
||||
})
|
||||
// Save deployment results
|
||||
setFormData({
|
||||
deploymentId: result.deploymentId,
|
||||
deploymentUrl: result.deploymentUrl,
|
||||
projectId: result.projectId,
|
||||
repositoryUrl: result.repositoryUrl
|
||||
})
|
||||
|
||||
// Move to success step after short delay
|
||||
setTimeout(() => {
|
||||
nextStep()
|
||||
}, 500)
|
||||
}
|
||||
}, 500)
|
||||
setDeploymentSuccess(true)
|
||||
toast.success('Template deployed successfully!')
|
||||
|
||||
// Move to success step after short delay
|
||||
setTimeout(() => {
|
||||
nextStep()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// Deploy repository project
|
||||
const deployRepositoryProject = async () => {
|
||||
if (!formData.githubRepo || !formData.selectedOrg) {
|
||||
throw new Error('Missing required repository deployment data')
|
||||
}
|
||||
|
||||
const config = {
|
||||
projectId: '', // Will be generated by backend
|
||||
organizationSlug: formData.selectedOrg,
|
||||
repository: formData.githubRepo,
|
||||
branch: 'main', // Default branch
|
||||
name: formData.projectName || formData.githubRepo.split('/').pop() || 'New Project',
|
||||
environmentVariables: formData.environmentVariables || []
|
||||
}
|
||||
|
||||
console.log('Deploying repository with config:', config)
|
||||
|
||||
const result = await deployRepository(config)
|
||||
|
||||
// Save deployment results
|
||||
setFormData({
|
||||
deploymentId: result.id,
|
||||
deploymentUrl: result.url,
|
||||
projectId: result.id
|
||||
})
|
||||
|
||||
setDeploymentSuccess(true)
|
||||
toast.success('Repository deployed successfully!')
|
||||
|
||||
// Move to success step after short delay
|
||||
setTimeout(() => {
|
||||
nextStep()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// Don't render UI until after mount to prevent hydration mismatch
|
||||
@ -101,41 +205,92 @@ export function DeployStep() {
|
||||
</div>
|
||||
|
||||
{/* Deploy header */}
|
||||
<h2 className={`text-2xl font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} text-center mb-2`}>Deploy</h2>
|
||||
<h2 className={`text-2xl font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} text-center mb-2`}>
|
||||
{isDeploying ? 'Deploying...' : 'Deploy'}
|
||||
</h2>
|
||||
<p className="text-center text-zinc-500 mb-8">
|
||||
Your deployment is configured and ready to go!
|
||||
{isDeploying
|
||||
? 'Your project is being deployed. This may take a few minutes.'
|
||||
: 'Review and confirm deployment'}
|
||||
</p>
|
||||
|
||||
{/* Repository info */}
|
||||
<div className={`border rounded-lg overflow-hidden mb-8 ${isDarkMode ? "border-zinc-700" : "border-zinc-300"}`}>
|
||||
<div className="p-5 flex items-center">
|
||||
<div className="mr-3">
|
||||
<Github className="h-5 w-5 text-zinc-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className={isDarkMode ? "text-white" : "text-zinc-900"}>{repoFullName}</div>
|
||||
<div className="text-sm text-zinc-500">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" className="inline-block mr-1">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
{branch}
|
||||
{/* Deployment Summary */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center justify-between">
|
||||
Deployment Summary
|
||||
<Badge variant="secondary">{deploymentInfo.type}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Github className="h-4 w-4 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{deploymentInfo.projectName}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{deploymentInfo.source}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deployment progress */}
|
||||
{isDeploying && deploymentProgress > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4 pt-2 text-xs">
|
||||
<div>
|
||||
<div className="text-muted-foreground">Organization</div>
|
||||
<div className="font-medium">{formData.selectedOrg}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Deployer</div>
|
||||
<div className="font-medium">{formData.selectedLrn ? 'LRN' : 'Auction'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{formData.environmentVariables && formData.environmentVariables.length > 0 && (
|
||||
<div className="pt-2">
|
||||
<div className="text-xs text-muted-foreground mb-1">Environment Variables</div>
|
||||
<div className="text-xs">
|
||||
{formData.environmentVariables.length} variable{formData.environmentVariables.length !== 1 ? 's' : ''} configured
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error Display */}
|
||||
{deploymentError && (
|
||||
<Alert className="mb-6" variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="font-medium">Deployment Failed</div>
|
||||
<div className="text-sm mt-1">{deploymentError}</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Success Display */}
|
||||
{deploymentSuccess && (
|
||||
<Alert className="mb-6">
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="font-medium">Deployment Successful!</div>
|
||||
<div className="text-sm mt-1">
|
||||
Your project has been deployed successfully. You'll be redirected to the project dashboard.
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Deployment Progress - Only show while deploying */}
|
||||
{isDeploying && (
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div className={`${isDarkMode ? "text-white" : "text-zinc-900"} text-sm`}>
|
||||
{deploymentProgress < 30 && "Preparing deployment..."}
|
||||
{deploymentProgress >= 30 && deploymentProgress < 90 && "Deploying your project..."}
|
||||
{deploymentProgress >= 90 && "Finalizing deployment..."}
|
||||
{isTemplateMode ? 'Creating repository from template...' : 'Deploying repository...'}
|
||||
</div>
|
||||
<div className="text-zinc-500 text-xs">{deploymentProgress}%</div>
|
||||
</div>
|
||||
<Progress value={deploymentProgress} className={`h-1 ${isDarkMode ? "bg-zinc-800" : "bg-zinc-200"}`} />
|
||||
<Progress value={undefined} className={`h-2 ${isDarkMode ? "bg-zinc-800" : "bg-zinc-200"}`} />
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
This process may take several minutes. Please do not close this window.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -145,12 +300,20 @@ export function DeployStep() {
|
||||
variant="outline"
|
||||
className={`${isDarkMode ? "text-zinc-400 border-zinc-700" : "text-zinc-600 border-zinc-300"} bg-transparent`}
|
||||
onClick={previousStep}
|
||||
disabled={isDeploying}
|
||||
disabled={isDeploying || deploymentSuccess}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{isDeploying ? (
|
||||
{deploymentSuccess ? (
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700 text-white"
|
||||
onClick={nextStep}
|
||||
>
|
||||
<CheckCircle2 className="mr-2 h-4 w-4" />
|
||||
Continue
|
||||
</Button>
|
||||
) : isDeploying ? (
|
||||
<Button
|
||||
className={`${isDarkMode ? "bg-zinc-700 text-zinc-300" : "bg-zinc-300 text-zinc-600"}`}
|
||||
disabled
|
||||
@ -162,8 +325,9 @@ export function DeployStep() {
|
||||
<Button
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white flex items-center"
|
||||
onClick={handlePayAndDeploy}
|
||||
disabled={deploymentError !== null}
|
||||
>
|
||||
Pay and Deploy
|
||||
{formData.deploymentType === 'auction' ? 'Pay and Deploy' : 'Deploy'}
|
||||
<svg className="ml-2 h-4 w-4" fill="none" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 12H19M19 12L13 6M19 12L13 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
@ -175,69 +339,79 @@ export function DeployStep() {
|
||||
|
||||
{/* Transaction Confirmation Dialog */}
|
||||
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<DialogContent className="bg-black border-zinc-800 text-white max-w-md">
|
||||
<DialogTitle className="text-white">Confirm Transaction</DialogTitle>
|
||||
<DialogDescription className="text-zinc-400">
|
||||
This is a dialog description.
|
||||
<DialogContent className="bg-background border max-w-md">
|
||||
<DialogTitle>Confirm Deployment</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review the deployment details before proceeding.
|
||||
</DialogDescription>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* From */}
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Project Info */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium text-white">From</h3>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-zinc-400">Address</div>
|
||||
<div className="text-sm text-white break-all font-mono">laconic1sdfjwel4jfkasfjgjal45ioasjj5jjlajfjj355</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-zinc-400">Public Key</div>
|
||||
<div className="text-sm text-white break-all font-mono">laconic1sdfjwel4jfkasfjgjal45ioasjj5jjlajfjj355</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-zinc-400">HD Path</div>
|
||||
<div className="text-sm text-white font-mono">m/44/118/0/0/0</div>
|
||||
<h3 className="text-sm font-medium">Project Details</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Name:</span>
|
||||
<span>{deploymentInfo.projectName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Type:</span>
|
||||
<span>{deploymentInfo.type}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Source:</span>
|
||||
<span className="font-mono text-xs">{deploymentInfo.source}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-lg font-medium text-white">Balance</div>
|
||||
<div className="text-lg text-white">129600</div>
|
||||
</div>
|
||||
|
||||
{/* To */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-lg font-medium text-white">To</div>
|
||||
<div className="text-sm text-white break-all font-mono">laconic1sdfjwel4jfkasfjgjal45ioasjj5jjlajfjj355</div>
|
||||
</div>
|
||||
|
||||
{/* Amount */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-lg font-medium text-white">Amount</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-zinc-400">Balance (aint)</div>
|
||||
<div className="text-sm text-white">129600</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-zinc-400">Amount (aint)</div>
|
||||
<div className="text-sm text-white">3000</div>
|
||||
{/* Wallet Info */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Payment Address</h3>
|
||||
<div className="p-2 bg-muted rounded text-xs font-mono break-all">
|
||||
{wallet?.address}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Deployer Info */}
|
||||
{formData.selectedLrn && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Deployer</h3>
|
||||
<div className="text-sm">
|
||||
<div className="font-mono text-xs">{formData.selectedLrn}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cost Info */}
|
||||
{formData.deploymentType === 'auction' && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">Auction Details</h3>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Max Price:</span>
|
||||
<span>{formData.maxPrice} aint</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Deployers:</span>
|
||||
<span>{formData.deployerCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-zinc-400 bg-zinc-900 border-zinc-800 hover:bg-zinc-800"
|
||||
onClick={handleCancelConfirm}
|
||||
>
|
||||
No, cancel
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-white text-black hover:bg-white/90"
|
||||
onClick={handleConfirmDeploy}
|
||||
>
|
||||
Yes, confirm
|
||||
Confirm Deployment
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
@ -6,7 +6,6 @@ import { useTheme } from 'next-themes'
|
||||
import { CheckCircle } from 'lucide-react'
|
||||
import { Button } from '@workspace/ui/components/button'
|
||||
import { useOnboarding } from '@/components/onboarding/useOnboarding'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function SuccessStep() {
|
||||
const router = useRouter()
|
||||
@ -22,20 +21,30 @@ export function SuccessStep() {
|
||||
}, [])
|
||||
|
||||
// Get deployment info from form data
|
||||
const repoName = formData.githubRepo ? formData.githubRepo.split('/').pop() : 'blogapp'
|
||||
const repoName = formData.githubRepo ? formData.githubRepo.split('/').pop() : (formData.projectName || 'project')
|
||||
const deploymentUrl = formData.deploymentUrl || `https://${repoName}.laconic.deploy`
|
||||
const projectId = formData.projectId || 'unknown-id'
|
||||
|
||||
// Function to copy URL to clipboard
|
||||
|
||||
// Handle "Visit Site" button
|
||||
|
||||
// Handle "View Project" button - navigates to project page
|
||||
const handleViewProject = () => {
|
||||
console.log('Navigating to project with ID:', projectId)
|
||||
resetOnboarding() // Reset state for next time
|
||||
|
||||
// Navigate to the project detail page using the GraphQL project ID
|
||||
router.push(`/projects/${providerParam}/ps/${projectId}`)
|
||||
}
|
||||
|
||||
// Auto-redirect after a delay (optional)
|
||||
useEffect(() => {
|
||||
if (mounted && projectId && projectId !== 'unknown-id') {
|
||||
const timer = setTimeout(() => {
|
||||
handleViewProject()
|
||||
}, 3000) // Auto-redirect after 3 seconds
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [mounted, projectId])
|
||||
|
||||
// Don't render UI until after mount to prevent hydration mismatch
|
||||
if (!mounted) {
|
||||
return null
|
||||
@ -54,12 +63,30 @@ export function SuccessStep() {
|
||||
|
||||
{/* Success header */}
|
||||
<h2 className={`text-2xl font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} text-center mb-2`}>
|
||||
Successfully
|
||||
Successfully Deployed!
|
||||
</h2>
|
||||
<p className="text-center text-zinc-500 mb-8">
|
||||
Your auction was successfully created
|
||||
Your project has been deployed successfully
|
||||
</p>
|
||||
|
||||
{/* Deployment summary */}
|
||||
<div className={`border rounded-md p-4 mb-6 ${isDarkMode ? "border-zinc-700" : "border-zinc-300"}`}>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Project:</span>
|
||||
<span className="font-medium">{repoName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">URL:</span>
|
||||
<span className="font-mono text-xs">{deploymentUrl}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Project ID:</span>
|
||||
<span className="font-mono text-xs">{projectId}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next steps section */}
|
||||
<div className="mb-8">
|
||||
<h3 className={`text-lg font-medium ${isDarkMode ? "text-white" : "text-zinc-900"} mb-4`}>Next steps</h3>
|
||||
@ -84,12 +111,20 @@ export function SuccessStep() {
|
||||
<Button
|
||||
className="w-full bg-white hover:bg-white/90 text-black flex items-center justify-center"
|
||||
onClick={handleViewProject}
|
||||
disabled={!projectId || projectId === 'unknown-id'}
|
||||
>
|
||||
View Project
|
||||
<svg className="ml-2 h-4 w-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 12H19M19 12L13 6M19 12L13 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{/* Manual navigation button if auto-redirect fails */}
|
||||
{projectId === 'unknown-id' && (
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Project ID not found. Please navigate to projects manually.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,4 +1,6 @@
|
||||
// FixedProjectCard.tsx - With original components
|
||||
// FixedProjectCard.tsx - With fixed navigation
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React, { type ComponentPropsWithoutRef, useCallback } from 'react'
|
||||
import { Card, CardContent, CardHeader } from '@workspace/ui/components/card'
|
||||
@ -9,7 +11,6 @@ import {
|
||||
AvatarFallback,
|
||||
AvatarImage
|
||||
} from '@workspace/ui/components/avatar'
|
||||
import router from 'next/router'
|
||||
|
||||
/**
|
||||
* Status types for project deployment status
|
||||
@ -92,6 +93,15 @@ const ProjectCardActions = ({
|
||||
setMenuOpen(!menuOpen);
|
||||
};
|
||||
|
||||
// Close menu when clicking outside
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = () => setMenuOpen(false);
|
||||
if (menuOpen) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
}, [menuOpen]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
@ -123,12 +133,6 @@ const ProjectCardActions = ({
|
||||
);
|
||||
};
|
||||
|
||||
function startDeployment(project: { project: any }) {
|
||||
// Store selected project in state or localStorage
|
||||
// Navigate to configuration page
|
||||
router.push(`/projects/github/ps/create/cr/configure?repo=${project.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* FixedProjectCard component
|
||||
*/
|
||||
@ -155,19 +159,21 @@ export const FixedProjectCard = ({
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
router.push(`/projects/github/ps/${project.id}/settings`);
|
||||
// Navigate to the settings tab - using the correct path structure
|
||||
router.push(`/projects/github/ps/${project.id}/set`);
|
||||
},
|
||||
[project.id, router]
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles click on the delete menu item
|
||||
* Handles click on the delete menu item - navigates to settings with delete intent
|
||||
*/
|
||||
const handleDeleteClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
router.push(`/projects/github/ps/${project.id}/settings`);
|
||||
// Navigate to settings and add a query parameter to trigger delete modal
|
||||
router.push(`/projects/github/ps/${project.id}/set?action=delete`);
|
||||
},
|
||||
[project.id, router]
|
||||
);
|
||||
@ -215,15 +221,6 @@ export const FixedProjectCard = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<ProjectStatusDot status={status} />
|
||||
<ProjectDeploymentInfo project={project} />
|
||||
{/* <div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
onClick={() => startDeployment(project)}
|
||||
variant="default"
|
||||
size="sm"
|
||||
>
|
||||
Deploy
|
||||
</Button>
|
||||
</div> */}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
@ -1,10 +1,12 @@
|
||||
// src/components/providers.tsx
|
||||
'use client'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { useEffect, useState } from 'react'
|
||||
import '@workspace/ui/globals.css'
|
||||
import { Toaster } from 'sonner'
|
||||
import { OctokitProviderWithRouter } from '@/context/OctokitProviderWithRouter'
|
||||
import { WalletProvider } from '@/context/WalletContext'
|
||||
import { WalletContextProvider } from '@/context/WalletContextProvider'
|
||||
import { BackendProvider } from '@/context/BackendContext'
|
||||
import { GQLClientProvider } from '@/context'
|
||||
import { GQLClient } from '@workspace/gql-client'
|
||||
|
||||
@ -16,10 +18,8 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
// Initialize GQLClient
|
||||
const initGQLClient = async () => {
|
||||
try {
|
||||
// Create a new instance of GQLClient
|
||||
const client = new GQLClient({
|
||||
endpoint: process.env.NEXT_PUBLIC_GQL_ENDPOINT || 'https://api.snowballtools-base.example',
|
||||
// Add any auth headers or other configuration needed
|
||||
gqlEndpoint: 'http://localhost:8000/graphql',
|
||||
})
|
||||
setGqlClient(client)
|
||||
} catch (error) {
|
||||
@ -32,8 +32,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
initGQLClient()
|
||||
}, [])
|
||||
|
||||
if (isLoading) {
|
||||
// You might want to add a loading indicator here
|
||||
if (isLoading || !gqlClient) {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
|
||||
@ -45,18 +44,16 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
disableTransitionOnChange
|
||||
enableColorScheme
|
||||
>
|
||||
<>
|
||||
<Toaster />
|
||||
<WalletProvider>
|
||||
<Toaster />
|
||||
<WalletContextProvider>
|
||||
<BackendProvider>
|
||||
<GQLClientProvider client={gqlClient}>
|
||||
<OctokitProviderWithRouter>
|
||||
{gqlClient && (
|
||||
<GQLClientProvider client={gqlClient}>
|
||||
{children}
|
||||
</GQLClientProvider>
|
||||
)}
|
||||
{children}
|
||||
</OctokitProviderWithRouter>
|
||||
</WalletProvider>
|
||||
</>
|
||||
</GQLClientProvider>
|
||||
</BackendProvider>
|
||||
</WalletContextProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
114
apps/deploy-fe/src/components/templates/TemplateCard.tsx
Normal file
114
apps/deploy-fe/src/components/templates/TemplateCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
37
apps/deploy-fe/src/components/templates/TemplateIcon.tsx
Normal file
37
apps/deploy-fe/src/components/templates/TemplateIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
109
apps/deploy-fe/src/components/templates/TemplateSelection.tsx
Normal file
109
apps/deploy-fe/src/components/templates/TemplateSelection.tsx
Normal 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>
|
||||
)
|
||||
}
|
46
apps/deploy-fe/src/constants/templates.tsx
Normal file
46
apps/deploy-fe/src/constants/templates.tsx
Normal 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,
|
||||
},
|
||||
]
|
111
apps/deploy-fe/src/context/BackendContext.tsx
Normal file
111
apps/deploy-fe/src/context/BackendContext.tsx
Normal 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
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
// src/context/OctokitContext.tsx
|
||||
import { Octokit, RequestError } from 'octokit'
|
||||
|
||||
import { useDebounceCallback } from 'usehooks-ts'
|
||||
@ -55,21 +56,40 @@ export const OctokitProvider = ({
|
||||
}) => {
|
||||
const [authToken, setAuthToken] = useState<string | null>(null)
|
||||
const [isAuth, setIsAuth] = useState(false)
|
||||
// const navigate = externalNavigate || internalNavigateconst
|
||||
const router = useRouter()
|
||||
const { orgSlug } = useParams()
|
||||
// const { toast, dismiss } = useToast()
|
||||
const client = useGQLClient()
|
||||
const gqlClient = useGQLClient()
|
||||
|
||||
/**
|
||||
* @function fetchUser
|
||||
* @description Fetches the user's GitHub token from the GQLClient.
|
||||
*/
|
||||
const fetchUser = useCallback(async () => {
|
||||
const { user } = await client.getUser()
|
||||
try {
|
||||
console.log('🔍 Fetching user data from GraphQL...')
|
||||
|
||||
setAuthToken(user.gitHubToken)
|
||||
}, [client])
|
||||
// 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)
|
||||
} else {
|
||||
console.log('❌ No GitHub token found in user data')
|
||||
setAuthToken(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching user:', error)
|
||||
setAuthToken(null)
|
||||
}
|
||||
}, [gqlClient])
|
||||
|
||||
/**
|
||||
* @function updateAuth
|
||||
@ -82,25 +102,29 @@ export const OctokitProvider = ({
|
||||
const octokit = useMemo(() => {
|
||||
if (!authToken) {
|
||||
setIsAuth(false)
|
||||
console.log('🐙 Creating Octokit without auth token')
|
||||
return new Octokit()
|
||||
}
|
||||
|
||||
console.log('🐙 Creating Octokit with auth token')
|
||||
setIsAuth(true)
|
||||
return new Octokit({ auth: authToken })
|
||||
}, [authToken])
|
||||
|
||||
// Only fetch user when GQL client is available
|
||||
useEffect(() => {
|
||||
fetchUser()
|
||||
}, [fetchUser])
|
||||
if (gqlClient) {
|
||||
console.log('🔄 GQL client available, fetching user...')
|
||||
fetchUser()
|
||||
} else {
|
||||
console.log('⏳ Waiting for GQL client...')
|
||||
}
|
||||
}, [gqlClient, fetchUser])
|
||||
|
||||
const debouncedUnauthorizedGithubHandler = useDebounceCallback(
|
||||
useCallback(
|
||||
(error: RequestError) => {
|
||||
toast.error(`GitHub authentication error: ${error.message}`, {
|
||||
// id: 'unauthorized-github-token',
|
||||
// variant: 'error',
|
||||
// onDismiss: dismiss
|
||||
})
|
||||
toast.error(`GitHub authentication error: ${error.message}`)
|
||||
|
||||
router.push(`/${orgSlug}/projects/create`)
|
||||
},
|
||||
@ -116,7 +140,9 @@ export const OctokitProvider = ({
|
||||
error instanceof RequestError &&
|
||||
error.status === UNAUTHORIZED_ERROR_CODE
|
||||
) {
|
||||
await client.unauthenticateGithub()
|
||||
if (gqlClient && typeof gqlClient.unauthenticateGithub === 'function') {
|
||||
await gqlClient.unauthenticateGithub()
|
||||
}
|
||||
await fetchUser()
|
||||
|
||||
debouncedUnauthorizedGithubHandler(error)
|
||||
@ -131,7 +157,7 @@ export const OctokitProvider = ({
|
||||
// Remove the interceptor when the component unmounts
|
||||
octokit.hook.remove('request', interceptor)
|
||||
}
|
||||
}, [octokit, client, debouncedUnauthorizedGithubHandler, fetchUser])
|
||||
}, [octokit, gqlClient, debouncedUnauthorizedGithubHandler, fetchUser])
|
||||
|
||||
return (
|
||||
<OctokitContext.Provider value={{ octokit, updateAuth, isAuth }}>
|
||||
|
@ -1,180 +1,43 @@
|
||||
import type React from 'react'
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState
|
||||
} from 'react'
|
||||
import { toast } from 'sonner'
|
||||
// src/context/WalletContext.tsx
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext } from 'react'
|
||||
|
||||
/**
|
||||
* @interface WalletContextType
|
||||
* @description Defines the structure of the WalletContext value.
|
||||
* @property {object | null} wallet - The wallet object containing id and address.
|
||||
* @property {boolean} isConnected - Indicates if the wallet is connected.
|
||||
* @property {function} connect - Function to connect the wallet.
|
||||
* @property {function} disconnect - Function to disconnect the wallet.
|
||||
* Wallet Context Interface
|
||||
*/
|
||||
interface WalletContextType {
|
||||
export interface WalletContextType {
|
||||
// Wallet state
|
||||
wallet: {
|
||||
id: string
|
||||
address?: string
|
||||
address: string
|
||||
} | null
|
||||
isConnected: boolean
|
||||
|
||||
// Connection states
|
||||
isConnected: boolean // SIWE authenticated + backend session
|
||||
hasWalletAddress: boolean // Just has wallet address
|
||||
isLoading: boolean // Connection/auth in progress
|
||||
|
||||
// Actions
|
||||
connect: () => Promise<void>
|
||||
disconnect: () => void
|
||||
checkSession: () => Promise<boolean>
|
||||
|
||||
// Debug info
|
||||
lastError?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @const WalletContext
|
||||
* @description Creates a context for managing wallet connection state.
|
||||
* Wallet Context
|
||||
*/
|
||||
const WalletContext = createContext<WalletContextType | undefined>(undefined)
|
||||
export const WalletContext = createContext<WalletContextType | undefined>(undefined)
|
||||
|
||||
/**
|
||||
* @component WalletProvider
|
||||
* @description Provides the WalletContext to its children.
|
||||
* @param {Object} props - Component props
|
||||
* @param {ReactNode} props.children - The children to render.
|
||||
* useWallet Hook
|
||||
* @description Hook to access wallet context. Must be used within WalletProvider.
|
||||
* @throws Error if used outside WalletProvider
|
||||
*/
|
||||
export const WalletProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children
|
||||
}) => {
|
||||
const [wallet, setWallet] = useState<WalletContextType['wallet']>(null)
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
|
||||
// useEffect(() => {
|
||||
// const handleWalletMessage = (event: MessageEvent) => {
|
||||
// if (event.origin !== process.env.NEXT_PUBLIC_WALLET_IFRAME_URL) return
|
||||
|
||||
// if (event.data.type === 'WALLET_ACCOUNTS_DATA') {
|
||||
// const address = event.data.data[0].address
|
||||
// setWallet({
|
||||
// id: address,
|
||||
// address: address
|
||||
// })
|
||||
// setIsConnected(true)
|
||||
// toast.success('Wallet Connected', {
|
||||
// // variant: 'success',
|
||||
// duration: 3000
|
||||
// // id: '',
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
// window.addEventListener('message', handleWalletMessage)
|
||||
// return () => window.removeEventListener('message', handleWalletMessage)
|
||||
// }, [])
|
||||
|
||||
// const connect = async () => {
|
||||
// const iframe = document.getElementById('checkBalanceIframe') as HTMLIFrameElement
|
||||
// // const iframe = document.getElementById('walletIframe') as HTMLIFrameElement
|
||||
// if (iframe?.contentWindow) {
|
||||
// iframe.contentWindow.postMessage(
|
||||
// {
|
||||
// type: 'REQUEST_WALLET_ACCOUNTS',
|
||||
// chainId: process.env.NEXT_PUBLIC_LACONICD_CHAIN_ID
|
||||
// },
|
||||
// process.env.NEXT_PUBLIC_WALLET_IFRAME_URL ?? ''
|
||||
// )
|
||||
// } else {
|
||||
// toast.error('Wallet Connection Failed', {
|
||||
// // description: 'Wallet iframe not found or not loaded',
|
||||
// // variant: 'error',
|
||||
// duration: 3000
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
const connect = async () => {
|
||||
console.log("🔌 Attempting to connect wallet...")
|
||||
console.log("🔧 Environment variables:", {
|
||||
NEXT_PUBLIC_WALLET_IFRAME_URL: process.env.NEXT_PUBLIC_WALLET_IFRAME_URL,
|
||||
NEXT_PUBLIC_LACONICD_CHAIN_ID: process.env.NEXT_PUBLIC_LACONICD_CHAIN_ID
|
||||
})
|
||||
|
||||
const iframe = document.getElementById('checkBalanceIframe') as HTMLIFrameElement
|
||||
console.log("📱 Iframe element:", iframe)
|
||||
|
||||
if (iframe?.contentWindow) {
|
||||
console.log("💬 Sending message to iframe:", {
|
||||
type: 'REQUEST_WALLET_ACCOUNTS',
|
||||
chainId: process.env.NEXT_PUBLIC_LACONICD_CHAIN_ID
|
||||
})
|
||||
|
||||
iframe.contentWindow.postMessage(
|
||||
{
|
||||
type: 'REQUEST_WALLET_ACCOUNTS',
|
||||
chainId: process.env.NEXT_PUBLIC_LACONICD_CHAIN_ID
|
||||
},
|
||||
process.env.NEXT_PUBLIC_WALLET_IFRAME_URL ?? ''
|
||||
)
|
||||
} else {
|
||||
console.error("❌ Iframe not found or not loaded:", {
|
||||
iframe: iframe,
|
||||
contentWindow: iframe?.contentWindow
|
||||
})
|
||||
toast.error('Wallet Connection Failed', {
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add logging to the message handler
|
||||
useEffect(() => {
|
||||
const handleWalletMessage = (event: MessageEvent) => {
|
||||
console.log("📨 Received message from wallet:", event.data)
|
||||
|
||||
if (event.origin !== process.env.NEXT_PUBLIC_WALLET_IFRAME_URL) {
|
||||
console.warn("⚠️ Message from unexpected origin:", event.origin)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.data.type === 'WALLET_ACCOUNTS_DATA') {
|
||||
console.log("✅ Wallet accounts data received:", event.data)
|
||||
const address = event.data.data[0].address
|
||||
setWallet({
|
||||
id: address,
|
||||
address: address
|
||||
})
|
||||
setIsConnected(true)
|
||||
toast.success('Wallet Connected', {
|
||||
duration: 3000
|
||||
})
|
||||
} else {
|
||||
console.log("📤 Received message type:", event.data.type, event.data)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleWalletMessage)
|
||||
return () => window.removeEventListener('message', handleWalletMessage)
|
||||
}, [])
|
||||
|
||||
const disconnect = () => {
|
||||
setWallet(null)
|
||||
setIsConnected(false)
|
||||
toast.info('Wallet Disconnected', {
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<WalletContext.Provider
|
||||
value={{ wallet, isConnected, connect, disconnect }}
|
||||
>
|
||||
{children}
|
||||
</WalletContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @function useWallet
|
||||
* @description A hook that provides access to the WalletContext.
|
||||
* @returns {WalletContextType} The wallet context value.
|
||||
* @throws {Error} If used outside of a WalletProvider.
|
||||
*/
|
||||
export const useWallet = () => {
|
||||
export const useWallet = (): WalletContextType => {
|
||||
const context = useContext(WalletContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useWallet must be used within a WalletProvider')
|
||||
|
@ -1,246 +1,255 @@
|
||||
import { AutoSignInIFrameModal } from '@/components/iframe/auto-sign-in'
|
||||
import axios from 'axios'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
// src/context/WalletProvider.tsx
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState
|
||||
} from 'react'
|
||||
import { SiweMessage, generateNonce } from 'siwe'
|
||||
import { type ReactNode, useState, useEffect, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { WalletContext, type WalletContextType } from './WalletContext'
|
||||
import { AutoSignInIFrameModal } from '@/components/iframe/auto-sign-in'
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: process.env.NEXT_PUBLIC_API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
},
|
||||
withCredentials: true
|
||||
})
|
||||
// Environment variables
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'
|
||||
const WALLET_IFRAME_URL = process.env.NEXT_PUBLIC_WALLET_IFRAME_URL || 'http://localhost:4000'
|
||||
const WALLET_IFRAME_ID = 'wallet-communication-iframe'
|
||||
|
||||
/**
|
||||
* @interface WalletContextType
|
||||
* @description Defines the structure of the WalletContext value.
|
||||
* @property {object | null} wallet - The wallet object containing id and address.
|
||||
* @property {boolean} isConnected - Indicates if the wallet is connected.
|
||||
* @property {boolean} isReady - Indicates if the app is ready to make API calls.
|
||||
* @property {function} connect - Function to connect the wallet.
|
||||
* @property {function} disconnect - Function to disconnect the wallet.
|
||||
*/
|
||||
interface WalletContextType {
|
||||
wallet: {
|
||||
id: string
|
||||
address?: string
|
||||
} | null
|
||||
isConnected: boolean
|
||||
isReady: boolean
|
||||
connect: () => Promise<void>
|
||||
disconnect: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* @const WalletContext
|
||||
* @description Creates a context for managing wallet connection state.
|
||||
*/
|
||||
const WalletContext = createContext<WalletContextType | undefined>(undefined)
|
||||
|
||||
/**
|
||||
* @component WalletContextProvider
|
||||
* @description Provides the WalletContext to its children.
|
||||
* @param {Object} props - Component props
|
||||
* @param {ReactNode} props.children - The children to render.
|
||||
*/
|
||||
export const WalletContextProvider: React.FC<{ children: ReactNode }> = ({
|
||||
children
|
||||
}) => {
|
||||
export const WalletContextProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
// Core wallet state
|
||||
const [wallet, setWallet] = useState<WalletContextType['wallet']>(null)
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [accountAddress, setAccountAddress] = useState<string>()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL
|
||||
const [hasWalletAddress, setHasWalletAddress] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [lastError, setLastError] = useState<string>()
|
||||
|
||||
// Update isReady state when connection changes
|
||||
useEffect(() => {
|
||||
if (isConnected) {
|
||||
// Add a small delay to ensure session is fully established
|
||||
const timer = setTimeout(() => {
|
||||
setIsReady(true)
|
||||
console.log('Wallet is now ready for API calls')
|
||||
}, 500)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
setIsReady(false)
|
||||
}, [isConnected])
|
||||
// Modal state for SIWE authentication
|
||||
const [showAuthModal, setShowAuthModal] = useState(false)
|
||||
|
||||
// Check session status on mount
|
||||
useEffect(() => {
|
||||
fetch(`${baseUrl}/auth/session`, {
|
||||
credentials: 'include'
|
||||
}).then((res) => {
|
||||
const path = pathname
|
||||
console.log(res)
|
||||
if (res.status !== 200) {
|
||||
setIsConnected(false)
|
||||
localStorage.clear()
|
||||
if (path !== '/login') {
|
||||
router.push('/login')
|
||||
}
|
||||
// Check if we have an active backend session
|
||||
const checkSession = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/auth/session`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
const sessionExists = response.ok
|
||||
setIsConnected(sessionExists)
|
||||
|
||||
if (sessionExists) {
|
||||
console.log('✅ Active wallet session found')
|
||||
} else {
|
||||
setIsConnected(true)
|
||||
if (path === '/login') {
|
||||
router.push('/')
|
||||
}
|
||||
console.log('❌ No active wallet session')
|
||||
}
|
||||
})
|
||||
}, [pathname, router, baseUrl])
|
||||
|
||||
// Handle wallet messages for account data
|
||||
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(() => {
|
||||
const handleWalletMessage = (event: MessageEvent) => {
|
||||
if (event.origin !== process.env.NEXT_PUBLIC_WALLET_IFRAME_URL) return
|
||||
console.log(event)
|
||||
// Security check
|
||||
if (event.origin !== WALLET_IFRAME_URL) {
|
||||
console.warn('⚠️ Message from unexpected origin:', event.origin)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('📨 Wallet message received:', {
|
||||
type: event.data.type,
|
||||
origin: event.origin,
|
||||
data: event.data.data
|
||||
})
|
||||
|
||||
if (event.data.type === 'WALLET_ACCOUNTS_DATA') {
|
||||
const address = event.data.data[0].address
|
||||
handleWalletAccountsData(event.data.data)
|
||||
}
|
||||
}
|
||||
|
||||
const handleWalletAccountsData = (data: any) => {
|
||||
let address: string | undefined
|
||||
|
||||
// Handle different data formats from wallet
|
||||
if (Array.isArray(data)) {
|
||||
if (typeof data[0] === 'string') {
|
||||
address = data[0]
|
||||
} else if (data[0]?.address) {
|
||||
address = data[0].address
|
||||
}
|
||||
}
|
||||
|
||||
if (address) {
|
||||
console.log('✅ Wallet address received:', address)
|
||||
|
||||
setWallet({
|
||||
id: address,
|
||||
address: address
|
||||
})
|
||||
setAccountAddress(address)
|
||||
setIsConnected(true)
|
||||
toast.success('Wallet Connected', {
|
||||
// variant: 'success',
|
||||
duration: 3000
|
||||
// id: '',
|
||||
setHasWalletAddress(true)
|
||||
setLastError(undefined)
|
||||
|
||||
// Check if we already have a session for this wallet
|
||||
checkSession().then(hasSession => {
|
||||
if (!hasSession) {
|
||||
// Need SIWE authentication
|
||||
console.log('🔐 Starting SIWE authentication...')
|
||||
setShowAuthModal(true)
|
||||
} else {
|
||||
toast.success('Wallet connected!')
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.error('❌ Could not extract address from wallet data:', data)
|
||||
setLastError('Invalid wallet data received')
|
||||
toast.error('Invalid wallet data received')
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleWalletMessage)
|
||||
return () => window.removeEventListener('message', handleWalletMessage)
|
||||
}, [])
|
||||
}, [checkSession])
|
||||
|
||||
// Handle sign-in response from the wallet iframe
|
||||
useEffect(() => {
|
||||
const handleSignInResponse = async (event: MessageEvent) => {
|
||||
if (event.origin !== process.env.NEXT_PUBLIC_WALLET_IFRAME_URL) return
|
||||
|
||||
if (event.data.type === 'SIGN_IN_RESPONSE') {
|
||||
try {
|
||||
const { success } = (
|
||||
await axiosInstance.post('/auth/validate', {
|
||||
message: event.data.data.message,
|
||||
signature: event.data.data.signature
|
||||
})
|
||||
).data
|
||||
|
||||
if (success === true) {
|
||||
setIsConnected(true)
|
||||
if (pathname === '/login') {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error signing in:', error)
|
||||
}
|
||||
}
|
||||
// Connect to wallet
|
||||
const connect = useCallback(async () => {
|
||||
if (isLoading) {
|
||||
console.log('⏸️ Connection already in progress')
|
||||
return
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleSignInResponse)
|
||||
setIsLoading(true)
|
||||
setLastError(undefined)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleSignInResponse)
|
||||
}
|
||||
}, [router, pathname])
|
||||
try {
|
||||
console.log('🔌 Attempting to connect wallet...')
|
||||
|
||||
// Initiate auto sign-in when account address is available
|
||||
useEffect(() => {
|
||||
const initiateAutoSignIn = async () => {
|
||||
if (!accountAddress) return
|
||||
// Find the wallet communication iframe
|
||||
const iframe = document.getElementById(WALLET_IFRAME_ID) as HTMLIFrameElement
|
||||
|
||||
const iframe = document.getElementById(
|
||||
'walletAuthFrame'
|
||||
) as HTMLIFrameElement
|
||||
|
||||
if (!iframe?.contentWindow) {
|
||||
console.error('Iframe not found or not loaded')
|
||||
return
|
||||
if (!iframe) {
|
||||
throw new Error('Wallet communication interface not found')
|
||||
}
|
||||
|
||||
const message = new SiweMessage({
|
||||
version: '1',
|
||||
domain: window.location.host,
|
||||
uri: window.location.origin,
|
||||
chainId: 1,
|
||||
address: accountAddress,
|
||||
nonce: generateNonce(),
|
||||
statement: 'Sign in With Ethereum.'
|
||||
}).prepareMessage()
|
||||
if (!iframe.contentWindow) {
|
||||
throw new Error('Wallet interface not loaded')
|
||||
}
|
||||
|
||||
iframe.contentWindow.postMessage(
|
||||
{
|
||||
type: 'AUTO_SIGN_IN',
|
||||
chainId: '1',
|
||||
message
|
||||
},
|
||||
process.env.NEXT_PUBLIC_WALLET_IFRAME_URL ?? ''
|
||||
)
|
||||
}
|
||||
console.log('📤 Sending wallet connection request...')
|
||||
|
||||
initiateAutoSignIn()
|
||||
}, [accountAddress])
|
||||
|
||||
const connect = async () => {
|
||||
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
|
||||
chainId: process.env.NEXT_PUBLIC_LACONICD_CHAIN_ID || '1'
|
||||
},
|
||||
process.env.NEXT_PUBLIC_WALLET_IFRAME_URL ?? ''
|
||||
WALLET_IFRAME_URL
|
||||
)
|
||||
} else {
|
||||
toast.error('Wallet Connection Failed', {
|
||||
// description: 'Wallet iframe not found or not loaded',
|
||||
// variant: 'error',
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const disconnect = () => {
|
||||
// Set a timeout for connection attempt
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!hasWalletAddress) {
|
||||
setLastError('Connection timeout')
|
||||
toast.error('Wallet connection timeout. Please try again.')
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, 15000) // 15 second timeout
|
||||
|
||||
// Clear timeout if we get an address
|
||||
if (hasWalletAddress) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to connect wallet'
|
||||
console.error('❌ Error connecting wallet:', error)
|
||||
setLastError(errorMessage)
|
||||
toast.error(errorMessage)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [isLoading, hasWalletAddress])
|
||||
|
||||
// Update loading state when address is received
|
||||
useEffect(() => {
|
||||
if (hasWalletAddress && isLoading) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [hasWalletAddress, isLoading])
|
||||
|
||||
// Handle successful SIWE authentication
|
||||
const handleAuthComplete = useCallback((success: boolean) => {
|
||||
if (success) {
|
||||
setIsConnected(true)
|
||||
toast.success('Wallet authentication complete!')
|
||||
console.log('✅ SIWE authentication successful')
|
||||
} else {
|
||||
console.log('❌ SIWE authentication failed')
|
||||
setLastError('SIWE authentication failed')
|
||||
toast.error('Wallet authentication failed')
|
||||
}
|
||||
setShowAuthModal(false)
|
||||
}, [])
|
||||
|
||||
// Disconnect wallet
|
||||
const disconnect = useCallback(() => {
|
||||
setWallet(null)
|
||||
setIsConnected(false)
|
||||
toast.info('Wallet Disconnected', {
|
||||
duration: 3000
|
||||
})
|
||||
setHasWalletAddress(false)
|
||||
setShowAuthModal(false)
|
||||
setLastError(undefined)
|
||||
|
||||
// Call backend logout
|
||||
fetch(`${BACKEND_URL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
}).catch(console.error)
|
||||
|
||||
toast.info('Wallet disconnected')
|
||||
}, [])
|
||||
|
||||
const contextValue: WalletContextType = {
|
||||
wallet,
|
||||
isConnected,
|
||||
hasWalletAddress,
|
||||
isLoading,
|
||||
connect,
|
||||
disconnect,
|
||||
checkSession,
|
||||
lastError
|
||||
}
|
||||
|
||||
return (
|
||||
<WalletContext.Provider
|
||||
value={{ wallet, isConnected, isReady, connect, disconnect }}
|
||||
>
|
||||
<WalletContext.Provider value={contextValue}>
|
||||
{children}
|
||||
{!isConnected && <AutoSignInIFrameModal />}
|
||||
|
||||
{/* Always-present hidden iframe for wallet communication */}
|
||||
<iframe
|
||||
id={WALLET_IFRAME_ID}
|
||||
src={WALLET_IFRAME_URL}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '-9999px',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
opacity: 0,
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
title="Wallet Communication Interface"
|
||||
/>
|
||||
|
||||
{/* SIWE Authentication Modal */}
|
||||
{showAuthModal && (
|
||||
<AutoSignInIFrameModal
|
||||
onAuthComplete={handleAuthComplete}
|
||||
onClose={() => setShowAuthModal(false)}
|
||||
/>
|
||||
)}
|
||||
</WalletContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @function useWallet
|
||||
* @description A hook that provides access to the WalletContext.
|
||||
* @returns {WalletContextType} The wallet context value.
|
||||
* @throws {Error} If used outside of a WalletContextProvider.
|
||||
*/
|
||||
export const useWallet = () => {
|
||||
const context = useContext(WalletContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useWallet must be used within a WalletContextProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
@ -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 };
|
||||
}
|
215
apps/deploy-fe/src/hooks/useAuthStatus.tsx
Normal file
215
apps/deploy-fe/src/hooks/useAuthStatus.tsx
Normal 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
|
||||
}
|
||||
}
|
@ -1,94 +1,131 @@
|
||||
// src/hooks/useDeployment.tsx
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useGQLClient } from '@/context'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface DeploymentConfig {
|
||||
repositoryUrl: string;
|
||||
branch: string;
|
||||
environmentVariables?: Record<string, string>;
|
||||
projectName?: string;
|
||||
customDomain?: string;
|
||||
// Define the structure of deployment configuration
|
||||
export interface DeploymentConfig {
|
||||
projectId?: string
|
||||
organizationSlug: string
|
||||
repository: string
|
||||
branch: string
|
||||
name: string
|
||||
environmentVariables?: Array<{
|
||||
key: string
|
||||
value: string
|
||||
environments: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
interface DeploymentResult {
|
||||
id: string;
|
||||
url: string;
|
||||
status: 'pending' | 'building' | 'ready' | 'error';
|
||||
// Define the structure of deployment result
|
||||
export interface DeploymentResult {
|
||||
id: string
|
||||
url?: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export function useDeployment() {
|
||||
const [isDeploying, setIsDeploying] = useState(false)
|
||||
const [deploymentResult, setDeploymentResult] = useState<DeploymentResult | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const gqlClient = useGQLClient()
|
||||
|
||||
// Function to create a new project and deploy it
|
||||
const deployRepository = async (config: DeploymentConfig): Promise<DeploymentResult> => {
|
||||
setIsDeploying(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// This is a placeholder query - you'll need to replace it with the actual GraphQL mutation
|
||||
// based on the snowballtools-base API schema
|
||||
const result = await gqlClient.mutate({
|
||||
mutation: `
|
||||
mutation CreateDeployment($input: CreateDeploymentInput!) {
|
||||
createDeployment(input: $input) {
|
||||
id
|
||||
url
|
||||
status
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
repositoryUrl: config.repositoryUrl,
|
||||
branch: config.branch,
|
||||
environmentVariables: config.environmentVariables || {},
|
||||
projectName: config.projectName,
|
||||
customDomain: config.customDomain
|
||||
}
|
||||
}
|
||||
})
|
||||
console.log('🚀 Starting repository deployment:', config)
|
||||
|
||||
const deployment = result.data.createDeployment
|
||||
setDeploymentResult(deployment)
|
||||
// Use the addProject mutation from your existing GraphQL client
|
||||
const projectResult = await gqlClient.addProject(
|
||||
config.organizationSlug,
|
||||
{
|
||||
name: config.name,
|
||||
repository: config.repository,
|
||||
prodBranch: config.branch,
|
||||
template: 'webapp', // Default template
|
||||
paymentAddress: "0x1ac42F4A25Ae0137d10a825a2e33e32de0F6B57E", // Should come from wallet
|
||||
txHash: "0x0000000000000000000000000000000000000000000000000000000000000000" // Placeholder
|
||||
},
|
||||
undefined, // lrn - will be handled in configure step
|
||||
undefined, // auctionParams - will be handled in configure step
|
||||
config.environmentVariables || []
|
||||
)
|
||||
|
||||
if (!projectResult.addProject?.id) {
|
||||
throw new Error('Failed to create project')
|
||||
}
|
||||
|
||||
toast.success('Project created successfully!')
|
||||
|
||||
// Wait a moment for deployment to start
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// Get the newly created project to find its deployment
|
||||
const projectData = await gqlClient.getProject(projectResult.addProject.id)
|
||||
|
||||
// Find the most recent deployment
|
||||
const deployment = projectData.project?.deployments?.[0]
|
||||
|
||||
const deploymentResult: DeploymentResult = {
|
||||
id: deployment?.id || projectResult.addProject.id,
|
||||
url: deployment?.applicationDeploymentRecordData?.url,
|
||||
status: deployment?.status || 'Building'
|
||||
}
|
||||
|
||||
setDeploymentResult(deploymentResult)
|
||||
toast.success('Deployment initiated successfully')
|
||||
return deployment
|
||||
return deploymentResult
|
||||
|
||||
} catch (error) {
|
||||
console.error('Deployment failed:', error)
|
||||
toast.error('Failed to deploy repository')
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
setError(errorMessage)
|
||||
toast.error(`Failed to deploy repository: ${errorMessage}`)
|
||||
throw error
|
||||
} finally {
|
||||
setIsDeploying(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getDeploymentStatus = async (deploymentId: string): Promise<string> => {
|
||||
// Function to check the status of a deployment
|
||||
const getDeploymentStatus = async (projectId: string) => {
|
||||
try {
|
||||
const result = await gqlClient.query({
|
||||
query: `
|
||||
query GetDeploymentStatus($id: ID!) {
|
||||
deployment(id: $id) {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
id: deploymentId
|
||||
}
|
||||
})
|
||||
|
||||
return result.data.deployment.status
|
||||
const result = await gqlClient.getProject(projectId)
|
||||
return result.project?.deployments?.[0]?.status
|
||||
} catch (error) {
|
||||
console.error('Failed to get deployment status:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Function to get all deployments for a project
|
||||
const getDeployments = async (projectId: string) => {
|
||||
try {
|
||||
return await gqlClient.getDeployments(projectId)
|
||||
} catch (error) {
|
||||
console.error('Failed to get deployments:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setDeploymentResult(null)
|
||||
setError(null)
|
||||
setIsDeploying(false)
|
||||
}
|
||||
|
||||
return {
|
||||
deployRepository,
|
||||
getDeploymentStatus,
|
||||
getDeployments,
|
||||
isDeploying,
|
||||
deploymentResult
|
||||
deploymentResult,
|
||||
error,
|
||||
reset
|
||||
}
|
||||
}
|
@ -62,13 +62,6 @@ export function useRepoData(repoId: string): UseRepoDataReturn {
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback to hardcoded token - ONLY for development
|
||||
if (!token && process.env.NODE_ENV === 'development') {
|
||||
// Store your token in .env.local instead, using this only as last resort
|
||||
token = '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';
|
||||
console.warn('Using hardcoded token - INSECURE. Use environment variables instead.');
|
||||
}
|
||||
|
||||
// Create Octokit instance with whatever token we found
|
||||
if (token) {
|
||||
setOctokit(new Octokit({ auth: token }));
|
||||
|
186
apps/deploy-fe/src/hooks/useTemplate.tsx
Normal file
186
apps/deploy-fe/src/hooks/useTemplate.tsx
Normal 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
|
||||
}
|
||||
}
|
@ -34,5 +34,9 @@
|
||||
"packageManager": "pnpm@10.5.1",
|
||||
"engines": {
|
||||
"node": ">=22.14.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"ethers": "^6.14.1",
|
||||
"siwe": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
90
pnpm-lock.yaml
generated
90
pnpm-lock.yaml
generated
@ -7,6 +7,13 @@ settings:
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
ethers:
|
||||
specifier: ^6.14.1
|
||||
version: 6.14.1
|
||||
siwe:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0(ethers@6.14.1)
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: ^1.9.4
|
||||
@ -112,7 +119,7 @@ importers:
|
||||
version: 7.7.1
|
||||
siwe:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0(ethers@5.8.0)
|
||||
version: 3.0.0(ethers@6.14.1)
|
||||
toml:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0
|
||||
@ -320,7 +327,7 @@ importers:
|
||||
version: 2.15.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
siwe:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.0(ethers@5.8.0)
|
||||
version: 3.0.0(ethers@6.14.1)
|
||||
sonner:
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
@ -399,7 +406,7 @@ importers:
|
||||
version: 1.9.4
|
||||
'@biomejs/monorepo':
|
||||
specifier: github:biomejs/biome
|
||||
version: https://codeload.github.com/biomejs/biome/tar.gz/7e51cd1e3139c46b4b45eab71936d1641230a566
|
||||
version: https://codeload.github.com/biomejs/biome/tar.gz/a82a1f2e50c5c9b22cc696960bc167631d1de455
|
||||
'@hookform/resolvers':
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.3(react-hook-form@7.54.2(react@19.0.0))
|
||||
@ -575,6 +582,9 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@adraffy/ens-normalize@1.10.1':
|
||||
resolution: {integrity: sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==}
|
||||
|
||||
'@alloc/quick-lru@5.2.0':
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
@ -720,9 +730,10 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@biomejs/monorepo@https://codeload.github.com/biomejs/biome/tar.gz/7e51cd1e3139c46b4b45eab71936d1641230a566':
|
||||
resolution: {tarball: https://codeload.github.com/biomejs/biome/tar.gz/7e51cd1e3139c46b4b45eab71936d1641230a566}
|
||||
'@biomejs/monorepo@https://codeload.github.com/biomejs/biome/tar.gz/a82a1f2e50c5c9b22cc696960bc167631d1de455':
|
||||
resolution: {tarball: https://codeload.github.com/biomejs/biome/tar.gz/a82a1f2e50c5c9b22cc696960bc167631d1de455}
|
||||
version: 0.0.0
|
||||
engines: {pnpm: 10.8.1}
|
||||
|
||||
'@cerc-io/laconic-registry-cli@0.2.10':
|
||||
resolution: {integrity: sha512-rwrZhFgYZiMh2k+9E/aiyRhFLApydRUwclATb0f6hsFAkxARuaibHsJNy7eF2N/AQ4d6HAvpkXACJoVrGOppmw==, tarball: https://git.vdb.to/api/packages/cerc-io/npm/%40cerc-io%2Flaconic-registry-cli/-/0.2.10/laconic-registry-cli-0.2.10.tgz}
|
||||
@ -1392,10 +1403,17 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@noble/curves@1.2.0':
|
||||
resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==}
|
||||
|
||||
'@noble/curves@1.8.1':
|
||||
resolution: {integrity: sha512-warwspo+UYUPep0Q+vtdVB4Ugn8GGQj8iyB3gnRWsztmUHTI3S1nhdiWNsPUGL0vud7JlRRk1XEu7Lq1KGTnMQ==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@noble/hashes@1.3.2':
|
||||
resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@noble/hashes@1.7.1':
|
||||
resolution: {integrity: sha512-B8XBPsn4vT/KJAGqDzbwztd+6Yte3P4V7iafm24bxgDe/mlRuK6xmWPuCNrKt2vDafZ8MfJLlchDG/vYafQEjQ==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
@ -2615,6 +2633,9 @@ packages:
|
||||
'@types/node@22.13.9':
|
||||
resolution: {integrity: sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==}
|
||||
|
||||
'@types/node@22.7.5':
|
||||
resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==}
|
||||
|
||||
'@types/pbkdf2@3.1.2':
|
||||
resolution: {integrity: sha512-uRwJqmiXmh9++aSu1VNEn3iIxWOhd8AHXNSdlaLfdAAdSTY9jYVeGWnzejM3dvrkbqE3/hyQkQQ29IFATEGlew==}
|
||||
|
||||
@ -2691,6 +2712,9 @@ packages:
|
||||
aes-js@3.0.0:
|
||||
resolution: {integrity: sha512-H7wUZRn8WpTq9jocdxQ2c8x2sKo9ZVmzfRE13GiNJXfp7NcKYEdvl3vspKjXox6RIG2VtaRe4JFvxG4rqp2Zuw==}
|
||||
|
||||
aes-js@4.0.0-beta.5:
|
||||
resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==}
|
||||
|
||||
agent-base@7.1.3:
|
||||
resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==}
|
||||
engines: {node: '>= 14'}
|
||||
@ -3504,6 +3528,10 @@ packages:
|
||||
ethers@5.8.0:
|
||||
resolution: {integrity: sha512-DUq+7fHrCg1aPDFCHx6UIPb3nmt2XMpM7Y/g2gLhsl3lIBqeAfOJIl1qEvRf2uq3BiKxmh6Fh5pfp2ieyek7Kg==}
|
||||
|
||||
ethers@6.14.1:
|
||||
resolution: {integrity: sha512-JnFiPFi3sK2Z6y7jZ3qrafDMwiXmU+6cNZ0M+kPq+mTy9skqEzwqAdFW3nb/em2xjlIVXX6Lz8ID6i3LmS4+fQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
ethjs-util@0.1.6:
|
||||
resolution: {integrity: sha512-CUnVOQq7gSpDHZVVrQW8ExxUETWrnrvXYvYz55wOU8Uj4VCgw56XC2B/fVqQN+f7gmrnRHSLVnFAwsCuNwji8w==}
|
||||
engines: {node: '>=6.5.0', npm: '>=3'}
|
||||
@ -5259,6 +5287,9 @@ packages:
|
||||
tslib@2.4.1:
|
||||
resolution: {integrity: sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==}
|
||||
|
||||
tslib@2.7.0:
|
||||
resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@ -5592,6 +5623,18 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.17.1:
|
||||
resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.18.0:
|
||||
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@ -5677,6 +5720,8 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@adraffy/ens-normalize@1.10.1': {}
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@apollo/client@3.13.3(@types/react@18.3.0)(graphql@16.10.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||
@ -5826,7 +5871,7 @@ snapshots:
|
||||
'@biomejs/cli-win32-x64@1.9.4':
|
||||
optional: true
|
||||
|
||||
'@biomejs/monorepo@https://codeload.github.com/biomejs/biome/tar.gz/7e51cd1e3139c46b4b45eab71936d1641230a566': {}
|
||||
'@biomejs/monorepo@https://codeload.github.com/biomejs/biome/tar.gz/a82a1f2e50c5c9b22cc696960bc167631d1de455': {}
|
||||
|
||||
'@cerc-io/laconic-registry-cli@0.2.10':
|
||||
dependencies:
|
||||
@ -6775,10 +6820,16 @@ snapshots:
|
||||
'@next/swc-win32-x64-msvc@15.2.1':
|
||||
optional: true
|
||||
|
||||
'@noble/curves@1.2.0':
|
||||
dependencies:
|
||||
'@noble/hashes': 1.3.2
|
||||
|
||||
'@noble/curves@1.8.1':
|
||||
dependencies:
|
||||
'@noble/hashes': 1.7.1
|
||||
|
||||
'@noble/hashes@1.3.2': {}
|
||||
|
||||
'@noble/hashes@1.7.1': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
@ -8112,6 +8163,10 @@ snapshots:
|
||||
dependencies:
|
||||
undici-types: 6.20.0
|
||||
|
||||
'@types/node@22.7.5':
|
||||
dependencies:
|
||||
undici-types: 6.19.8
|
||||
|
||||
'@types/pbkdf2@3.1.2':
|
||||
dependencies:
|
||||
'@types/node': 20.17.23
|
||||
@ -8189,6 +8244,8 @@ snapshots:
|
||||
|
||||
aes-js@3.0.0: {}
|
||||
|
||||
aes-js@4.0.0-beta.5: {}
|
||||
|
||||
agent-base@7.1.3: {}
|
||||
|
||||
aggregate-error@3.1.0:
|
||||
@ -9143,6 +9200,19 @@ snapshots:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
ethers@6.14.1:
|
||||
dependencies:
|
||||
'@adraffy/ens-normalize': 1.10.1
|
||||
'@noble/curves': 1.2.0
|
||||
'@noble/hashes': 1.3.2
|
||||
'@types/node': 22.7.5
|
||||
aes-js: 4.0.0-beta.5
|
||||
tslib: 2.7.0
|
||||
ws: 8.17.1
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
|
||||
ethjs-util@0.1.6:
|
||||
dependencies:
|
||||
is-hex-prefixed: 1.0.0
|
||||
@ -10727,11 +10797,11 @@ snapshots:
|
||||
is-arrayish: 0.3.2
|
||||
optional: true
|
||||
|
||||
siwe@3.0.0(ethers@5.8.0):
|
||||
siwe@3.0.0(ethers@6.14.1):
|
||||
dependencies:
|
||||
'@spruceid/siwe-parser': 3.0.0
|
||||
'@stablelib/random': 1.0.2
|
||||
ethers: 5.8.0
|
||||
ethers: 6.14.1
|
||||
|
||||
slash@3.0.0: {}
|
||||
|
||||
@ -11057,6 +11127,8 @@ snapshots:
|
||||
|
||||
tslib@2.4.1: {}
|
||||
|
||||
tslib@2.7.0: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsscmp@1.0.6: {}
|
||||
@ -11324,6 +11396,8 @@ snapshots:
|
||||
|
||||
ws@7.5.10: {}
|
||||
|
||||
ws@8.17.1: {}
|
||||
|
||||
ws@8.18.0: {}
|
||||
|
||||
xss@1.0.15:
|
||||
|
Loading…
Reference in New Issue
Block a user