diff --git a/examples/supa-builder/README.md b/examples/supa-builder/README.md index f3c4808e41a..ea62752ce4a 100644 --- a/examples/supa-builder/README.md +++ b/examples/supa-builder/README.md @@ -142,6 +142,8 @@ supabase functions deploy create-project # Verify deployment supabase functions list + +# Make sure to turn of JWT verification in the Supabsae Dashboard -> Edge Functions ``` ### Step 9: Configure SSO Authentication diff --git a/examples/supa-builder/TODO.md b/examples/supa-builder/TODO.md index 1772266010d..158e6705cfb 100644 --- a/examples/supa-builder/TODO.md +++ b/examples/supa-builder/TODO.md @@ -13,20 +13,20 @@ - Test against fresh database - Update migration numbering -- [ ] Update UI +- [X] Update UI - Improve overall design and polish - Better loading states - Enhanced error messages - Consistent styling across all pages -- [ ] Show waiting page until form is successfully submitted and project is created +- [X] Show waiting page until form is successfully submitted and project is created - Display progress indicator during project creation - Show estimated time (1-2 minutes) - Poll for project status updates - Handle timeout scenarios - Redirect to project detail page on success -- [ ] Return project ref and anon key +- [X] Return project ref and anon key - Display project credentials immediately after creation - Add copy-to-clipboard functionality - Show project URL diff --git a/examples/supa-builder/app/actions/projects.ts b/examples/supa-builder/app/actions/projects.ts index a44f8694a20..2c2f60d90ab 100644 --- a/examples/supa-builder/app/actions/projects.ts +++ b/examples/supa-builder/app/actions/projects.ts @@ -42,6 +42,12 @@ export interface ActionResult { success: boolean data?: T error?: string + errorDetails?: { + statusCode?: number + statusText?: string + requestUrl?: string + responseBody?: unknown + } } // ============================================================================ @@ -51,6 +57,8 @@ export interface ActionResult { export async function createProject( input: CreateProjectInput ): Promise> { + const functionUrl = process.env.NEXT_PUBLIC_SUPABASE_URL + '/functions/v1/create-project' + try { const supabase = await createClient() @@ -61,33 +69,111 @@ export async function createProject( } = await supabase.auth.getSession() if (sessionError || !session) { + console.error('[createProject] Session error:', sessionError) return { success: false, error: 'You must be logged in to create a project', } } - // Call edge function to create project - const functionUrl = process.env.NEXT_PUBLIC_SUPABASE_URL + '/functions/v1/create-project' - - const response = await fetch(functionUrl, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${session.access_token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(input), + // Log the request we're about to make + console.log('[createProject] Calling edge function:', { + url: functionUrl, + project_name: input.project_name, + organization_id: input.organization_id, + region: input.region, + hasToken: !!session.access_token, + tokenLength: session.access_token?.length, }) - const result = await response.json() - - if (!response.ok || !result.success) { + // Call edge function to create project + let response: Response + try { + response = await fetch(functionUrl, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${session.access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(input), + }) + } catch (fetchError) { + // Network error - couldn't reach edge function + console.error('[createProject] Network error reaching edge function:', { + error: fetchError, + url: functionUrl, + message: fetchError instanceof Error ? fetchError.message : String(fetchError), + }) return { success: false, - error: result.error || 'Failed to create project', + error: `Network error: Could not reach edge function. ${fetchError instanceof Error ? fetchError.message : 'Unknown error'}`, + errorDetails: { + requestUrl: functionUrl, + }, } } + // Log response status + console.log('[createProject] Edge function response:', { + status: response.status, + statusText: response.statusText, + ok: response.ok, + headers: Object.fromEntries(response.headers.entries()), + }) + + // Try to parse response body + let result: any + const responseText = await response.text() + console.log('[createProject] Response body:', responseText) + + try { + result = JSON.parse(responseText) + } catch (parseError) { + console.error('[createProject] Failed to parse response as JSON:', { + parseError, + responseText: responseText.substring(0, 500), // Log first 500 chars + statusCode: response.status, + }) + return { + success: false, + error: `Invalid response from edge function (HTTP ${response.status}). Response was not valid JSON.`, + errorDetails: { + statusCode: response.status, + statusText: response.statusText, + requestUrl: functionUrl, + responseBody: responseText.substring(0, 500), + }, + } + } + + // Check if request was successful + if (!response.ok || !result.success) { + const errorMessage = result.error || result.message || 'Failed to create project' + console.error('[createProject] Edge function returned error:', { + status: response.status, + statusText: response.statusText, + error: errorMessage, + fullResponse: result, + }) + + return { + success: false, + error: `${errorMessage} (HTTP ${response.status})`, + errorDetails: { + statusCode: response.status, + statusText: response.statusText, + requestUrl: functionUrl, + responseBody: result, + }, + } + } + + // Success! + console.log('[createProject] Project created successfully:', { + project_id: result.project_id, + project_ref: result.project_ref, + }) + // Revalidate projects page revalidatePath('/projects') @@ -100,10 +186,17 @@ export async function createProject( }, } } catch (error) { - console.error('Error creating project:', error) + console.error('[createProject] Unexpected error:', { + error, + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }) return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred', + errorDetails: { + requestUrl: functionUrl, + }, } } } @@ -321,36 +414,75 @@ export async function resumeProject(projectId: string): Promise { // ============================================================================ export async function deleteProject(projectId: string): Promise { + const functionUrl = process.env.NEXT_PUBLIC_SUPABASE_URL + '/functions/v1/delete-project' + try { const supabase = await createClient() - // Get current user + // Get current session const { - data: { user }, - error: authError, - } = await supabase.auth.getUser() + data: { session }, + error: sessionError, + } = await supabase.auth.getSession() - if (authError || !user) { + if (sessionError || !session) { + console.error('[deleteProject] Session error:', sessionError) return { success: false, error: 'You must be logged in to delete projects', } } - // Soft delete using helper function - const { error } = await supabase.rpc('soft_delete_project', { - p_project_id: projectId, - p_actor_id: user.id, - p_actor_email: user.email!, + console.log('[deleteProject] Calling edge function:', { + url: functionUrl, + project_id: projectId, }) - if (error) { + // Call edge function to delete project + const response = await fetch(functionUrl, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${session.access_token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ project_id: projectId }), + }) + + console.log('[deleteProject] Edge function response:', { + status: response.status, + statusText: response.statusText, + ok: response.ok, + }) + + // Try to parse response body + let result: any + const responseText = await response.text() + console.log('[deleteProject] Response body:', responseText) + + try { + result = JSON.parse(responseText) + } catch (parseError) { + console.error('[deleteProject] Failed to parse response as JSON') return { success: false, - error: error.message, + error: `Invalid response from edge function (HTTP ${response.status})`, } } + // Check if request was successful + if (!response.ok || !result.success) { + const errorMessage = result.error || result.message || 'Failed to delete project' + console.error('[deleteProject] Edge function returned error:', errorMessage) + + return { + success: false, + error: `${errorMessage} (HTTP ${response.status})`, + } + } + + // Success! + console.log('[deleteProject] Project deleted successfully:', projectId) + // Revalidate projects page revalidatePath('/projects') @@ -358,7 +490,7 @@ export async function deleteProject(projectId: string): Promise { success: true, } } catch (error) { - console.error('Error deleting project:', error) + console.error('[deleteProject] Unexpected error:', error) return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred', diff --git a/examples/supa-builder/app/api/ai/sql/route.ts b/examples/supa-builder/app/api/ai/sql/route.ts new file mode 100644 index 00000000000..3f2a5a89a86 --- /dev/null +++ b/examples/supa-builder/app/api/ai/sql/route.ts @@ -0,0 +1,110 @@ +import type { paths } from '@/lib/management-api-schema' +import { listTablesSql } from '@/lib/pg-meta' +import { NextResponse } from 'next/server' +import OpenAI from 'openai' +import createClient from 'openapi-fetch' + +const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, +}) + +const client = createClient({ + baseUrl: 'https://api.supabase.com', + headers: { + Authorization: `Bearer ${process.env.SUPABASE_MANAGEMENT_API_TOKEN}`, + }, +}) + +// Function to get database schema +async function getDbSchema(projectRef: string) { + const token = process.env.SUPABASE_MANAGEMENT_API_TOKEN + if (!token) { + throw new Error('Supabase Management API token is not configured.') + } + + const sql = listTablesSql() + + const { data, error } = await client.POST('/v1/projects/{ref}/database/query', { + params: { + path: { + ref: projectRef, + }, + }, + body: { + query: sql, + read_only: true, + }, + }) + + if (error) { + throw error + } + + return data as any +} + +function formatSchemaForPrompt(schema: any) { + let schemaString = '' + if (schema && Array.isArray(schema)) { + schema.forEach((table: any) => { + const columnInfo = table.columns.map((c: any) => `${c.name} (${c.data_type})`) + schemaString += `Table "${table.name}" has columns: ${columnInfo.join(', ')}.\n` + }) + } + return schemaString +} + +export async function POST(request: Request) { + try { + const { prompt, projectRef } = await request.json() + + if (!prompt) { + return NextResponse.json({ message: 'Prompt is required.' }, { status: 400 }) + } + if (!projectRef) { + return NextResponse.json({ message: 'projectRef is required.' }, { status: 400 }) + } + + // Implement your permission check here (e.g. check if the user is a member of the project) + // In this example, everyone can access all projects + const userHasPermissionForProject = Boolean(projectRef) + + if (!userHasPermissionForProject) { + return NextResponse.json( + { message: 'You do not have permission to access this project.' }, + { status: 403 } + ) + } + + // 1. Get database schema + const schema = await getDbSchema(projectRef) + const formattedSchema = formatSchemaForPrompt(schema) + + // 2. Create a prompt for OpenAI + const systemPrompt = `You are an expert SQL assistant. Given the following database schema, write a SQL query that answers the user's question. Return only the SQL query, do not include any explanations or markdown.\n\nSchema:\n${formattedSchema}` + + // 3. Call OpenAI to generate SQL using responses.create (plain text output) + const response = await openai.responses.create({ + model: 'gpt-4.1', + instructions: systemPrompt, // Use systemPrompt as instructions + input: prompt, // User's question + }) + + const sql = response.output_text + + if (!sql) { + return NextResponse.json( + { message: 'Could not generate SQL from the prompt.' }, + { status: 500 } + ) + } + + // 4. Return the generated SQL + return NextResponse.json({ sql }) + } catch (error: any) { + console.error('AI SQL generation error:', error) + const errorMessage = error.message || 'An unexpected error occurred.' + const status = error.response?.status || 500 + return NextResponse.json({ message: errorMessage }, { status }) + } +} diff --git a/examples/supa-builder/app/api/supabase-proxy/[...path]/route.ts b/examples/supa-builder/app/api/supabase-proxy/[...path]/route.ts new file mode 100644 index 00000000000..a81c1930d12 --- /dev/null +++ b/examples/supa-builder/app/api/supabase-proxy/[...path]/route.ts @@ -0,0 +1,130 @@ +import { NextResponse } from 'next/server' +import { createClient } from '@/lib/supabase/server' + +async function forwardToSupabaseAPI(request: Request, method: string, params: { path: string[] }) { + // eslint-disable-next-line turbo/no-undeclared-env-vars + if (!process.env.SUPABASE_MANAGEMENT_API_TOKEN) { + console.error('Supabase Management API token is not configured.') + return NextResponse.json({ message: 'Server configuration error.' }, { status: 500 }) + } + + const { path } = params + const apiPath = path.join('/') + + const url = new URL(request.url) + url.protocol = 'https' + url.hostname = 'api.supabase.com' + url.port = '443' + url.pathname = apiPath + + const projectRef = path[2] + + // Check user permission using RLS policies + const supabase = await createClient() + const { data: { user }, error: authError } = await supabase.auth.getUser() + + if (authError || !user) { + return NextResponse.json( + { message: 'You must be logged in to access projects.' }, + { status: 401 } + ) + } + + // Verify user has access to this project via RLS + const { data: project, error: projectError } = await supabase + .from('projects') + .select('id') + .eq('project_ref', projectRef) + .is('deleted_at', null) + .single() + + if (projectError || !project) { + return NextResponse.json( + { message: 'You do not have permission to access this project.' }, + { status: 403 } + ) + } + + try { + const forwardHeaders: HeadersInit = { + // eslint-disable-next-line turbo/no-undeclared-env-vars + Authorization: `Bearer ${process.env.SUPABASE_MANAGEMENT_API_TOKEN}`, + } + + // Copy relevant headers from the original request + const contentType = request.headers.get('content-type') + if (contentType) { + forwardHeaders['Content-Type'] = contentType + } + + const fetchOptions: RequestInit = { + method, + headers: forwardHeaders, + } + + // Include body for methods that support it + if (method !== 'GET' && method !== 'HEAD') { + try { + const body = await request.text() + if (body) { + fetchOptions.body = body + } + } catch (error) { + // Handle cases where body is not readable + console.warn('Could not read request body:', error) + } + } + + const response = await fetch(url, fetchOptions) + + // Get response body + const responseText = await response.text() + let responseData + + try { + responseData = responseText ? JSON.parse(responseText) : null + } catch { + responseData = responseText + } + + // Return the response with the same status + return NextResponse.json(responseData, { status: response.status }) + } catch (error: any) { + console.error('Supabase API proxy error:', error) + const errorMessage = error.message || 'An unexpected error occurred.' + return NextResponse.json({ message: errorMessage }, { status: 500 }) + } +} + +export async function GET(request: Request, { params }: { params: Promise<{ path: string[] }> }) { + const resolvedParams = await params + return forwardToSupabaseAPI(request, 'GET', resolvedParams) +} + +export async function HEAD(request: Request, { params }: { params: Promise<{ path: string[] }> }) { + const resolvedParams = await params + return forwardToSupabaseAPI(request, 'HEAD', resolvedParams) +} + +export async function POST(request: Request, { params }: { params: Promise<{ path: string[] }> }) { + const resolvedParams = await params + return forwardToSupabaseAPI(request, 'POST', resolvedParams) +} + +export async function PUT(request: Request, { params }: { params: Promise<{ path: string[] }> }) { + const resolvedParams = await params + return forwardToSupabaseAPI(request, 'PUT', resolvedParams) +} + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ path: string[] }> } +) { + const resolvedParams = await params + return forwardToSupabaseAPI(request, 'DELETE', resolvedParams) +} + +export async function PATCH(request: Request, { params }: { params: Promise<{ path: string[] }> }) { + const resolvedParams = await params + return forwardToSupabaseAPI(request, 'PATCH', resolvedParams) +} diff --git a/examples/supa-builder/app/layout.tsx b/examples/supa-builder/app/layout.tsx index f7fa87eb875..f81ece67b39 100644 --- a/examples/supa-builder/app/layout.tsx +++ b/examples/supa-builder/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { Toaster } from 'sonner'; const geistSans = Geist({ variable: "--font-geist-sans", @@ -28,6 +29,7 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased`} > {children} + ); diff --git a/examples/supa-builder/app/login/page.tsx b/examples/supa-builder/app/login/page.tsx index f14d90cfdad..e302935ed7a 100644 --- a/examples/supa-builder/app/login/page.tsx +++ b/examples/supa-builder/app/login/page.tsx @@ -23,45 +23,20 @@ export default async function LoginPage() { } return ( -
-
+
+
{/* Header */} -
-

+
+

SupaBuilder

-

- Sign in to manage your Supabase projects +

+ Enable your team to create and manage Supabase projects with proper governance, audit trails, and role-based access control.

- {/* Login Card */} -
-

- Sign In -

- - {/* SSO Login Buttons */} -
- - - {/* Add more providers as needed */} - {/* */} - {/* */} -
- - {/* Info Text */} -
-

Sign in with your organization account

-
-
- - {/* Footer Info */} -
-

- First-time users: The first person to sign in from your organization will automatically receive admin privileges. -

-
+ {/* Sign In Button */} +

) diff --git a/examples/supa-builder/app/projects/[id]/page.tsx b/examples/supa-builder/app/projects/[id]/page.tsx index 6462e248301..fa273cc1d0c 100644 --- a/examples/supa-builder/app/projects/[id]/page.tsx +++ b/examples/supa-builder/app/projects/[id]/page.tsx @@ -1,13 +1,18 @@ /** * Project Detail Page * - * Server component that displays detailed information about a single project. + * Server component that displays project information and Platform Kit UI. */ import Link from 'next/link' import { redirect, notFound } from 'next/navigation' import { createClient } from '@/lib/supabase/server' import { getProject } from '@/app/actions/projects' +import ProjectInfo from '@/components/ProjectInfo' +import PlatformKitEmbed from '@/components/PlatformKitEmbed' +import Header from '@/components/Header' +import DeleteProjectButton from '@/components/DeleteProjectButton' +import { ArrowLeft } from 'lucide-react' interface ProjectDetailPageProps { params: Promise<{ @@ -45,26 +50,18 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro }) const isAdmin = role === 'admin' - // Get audit logs (if admin or creator) - const { data: auditLogs } = await supabase - .from('project_audit_logs') - .select('*') - .eq('project_id', project.id) - .order('created_at', { ascending: false }) - .limit(10) - const getStatusBadge = (status: typeof project.status) => { const styles = { - provisioning: 'bg-yellow-100 text-yellow-800 border-yellow-200', - active: 'bg-green-100 text-green-800 border-green-200', - paused: 'bg-gray-100 text-gray-800 border-gray-200', - failed: 'bg-red-100 text-red-800 border-red-200', - deleted: 'bg-red-100 text-red-800 border-red-200', + provisioning: 'bg-amber-500/10 text-amber-500 border-amber-500/20', + active: 'bg-[#3ECF8E]/10 text-[#3ECF8E] border-[#3ECF8E]/20', + paused: 'bg-gray-500/10 text-gray-400 border-gray-500/20', + failed: 'bg-red-500/10 text-red-500 border-red-500/20', + deleted: 'bg-red-500/10 text-red-500 border-red-500/20', } return ( @@ -74,151 +71,67 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro } return ( -
- {/* Header */} -
- - ← Back to Projects - -
-
-

{project.project_name}

-

Project Reference: {project.project_ref}

-
- {getStatusBadge(project.status)} -
-
- - {/* Project Details Grid */} -
- {/* Basic Information */} -
-

Basic Information

-
+
+
+
+ {/* Header */} +
+ + + Back to Projects + +
-
Organization ID
-
{project.organization_id}
+

{project.project_name}

+

{project.project_ref}

-
-
Region
-
{project.region}
-
-
-
Created By
-
{project.creator_email}
-
-
-
Created At
-
- {new Date(project.created_at).toLocaleString()} -
-
- {project.purpose && ( -
-
Purpose
-
{project.purpose}
-
- )} -
-
- - {/* Credentials */} -
-

API Credentials

-
-
- - - https://{project.project_ref}.supabase.co - -
-
- - - {project.anon_key} - -
- {isAdmin && ( -
-

- Admin Note: Service role key is encrypted in the database. - Contact system administrator for access. -

-
- )} + {getStatusBadge(project.status)}
-
- {/* Description */} - {project.description && ( -
-

Description

-

{project.description}

+ {/* Project Info - Collapsible */} +
+
- )} - {/* Quick Links */} - + {/* Admin Actions */} + {isAdmin && project.status !== 'deleted' && ( +
+ +
+ )} - {/* Audit Logs */} - {auditLogs && auditLogs.length > 0 && ( -
-

Recent Activity

-
- {auditLogs.map((log) => ( -
- - {log.action} - -
-

- {log.actor_email} -

-

- {new Date(log.created_at).toLocaleString()} -

+ {/* Platform Kit UI */} + {project.status === 'active' ? ( + + ) : ( +
+
+

+ {project.status === 'provisioning' && 'Project Provisioning'} + {project.status === 'paused' && 'Project Paused'} + {project.status === 'failed' && 'Provisioning Failed'} +

+

+ {project.status === 'provisioning' && 'Your project is being provisioned. This usually takes 1-2 minutes.'} + {project.status === 'paused' && 'This project has been paused. Contact an admin to resume it.'} + {project.status === 'failed' && 'Project provisioning failed. Please contact support.'} +

+ {project.status === 'provisioning' && ( +
+
-
- ))} + )} +
-
- )} + )} +
) } diff --git a/examples/supa-builder/app/projects/[id]/provisioning/page.tsx b/examples/supa-builder/app/projects/[id]/provisioning/page.tsx new file mode 100644 index 00000000000..ebc7a943dd2 --- /dev/null +++ b/examples/supa-builder/app/projects/[id]/provisioning/page.tsx @@ -0,0 +1,165 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useRouter } from 'next/navigation' +import { getProject, type Project } from '@/app/actions/projects' +import { Loader2, CheckCircle2, Clock, AlertCircle } from 'lucide-react' + +export default function ProvisioningPage({ params }: { params: { id: string } }) { + const router = useRouter() + const [project, setProject] = useState(null) + const [error, setError] = useState(null) + const [dots, setDots] = useState('') + + // Animated dots effect + useEffect(() => { + const interval = setInterval(() => { + setDots((prev) => (prev.length >= 3 ? '' : prev + '.')) + }, 500) + return () => clearInterval(interval) + }, []) + + // Poll project status + useEffect(() => { + const poll = async () => { + const result = await getProject(params.id) + + if (result.success && result.data) { + setProject(result.data) + + // Redirect when project is active + if (result.data.status === 'active') { + router.push(`/projects/${params.id}`) + } + + // Show error if provisioning failed + if (result.data.status === 'failed') { + setError('Project provisioning failed. Please contact support.') + } + } else { + setError(result.error || 'Failed to fetch project status') + } + } + + poll() + const interval = setInterval(poll, 3000) // Poll every 3 seconds + return () => clearInterval(interval) + }, [params.id, router]) + + if (error) { + return ( +
+
+
+ +
+

Provisioning Failed

+

{error}

+ +
+
+ ) + } + + return ( +
+
+ {/* Header */} +
+
+ +
+

+ Creating Your Project{dots} +

+ {project && ( +

+ {project.project_name} +

+ )} +
+ + {/* Progress Steps */} +
+ } + title="Project Registered" + description="Your project has been registered in the system" + completed={true} + /> + + } + title="Infrastructure Provisioning" + description="Setting up database, storage, and authentication services" + completed={false} + inProgress={true} + /> + + } + title="Finalizing Setup" + description="Configuring API keys and network settings" + completed={false} + /> +
+ + {/* Info Box */} +
+

+ This usually takes 1-2 minutes. You'll be automatically redirected when ready. +

+
+ + {/* Manual navigation */} +
+ +
+
+
+ ) +} + +function ProvisioningStep({ + icon, + title, + description, + completed, + inProgress = false, +}: { + icon: React.ReactNode + title: string + description: string + completed: boolean + inProgress?: boolean +}) { + return ( +
+
+ {icon} +
+
+

+ {title} +

+

+ {description} +

+
+
+ ) +} diff --git a/examples/supa-builder/app/projects/new/page.tsx b/examples/supa-builder/app/projects/new/page.tsx index c98c4aa72cd..f3611e93e4f 100644 --- a/examples/supa-builder/app/projects/new/page.tsx +++ b/examples/supa-builder/app/projects/new/page.tsx @@ -8,6 +8,8 @@ import { redirect } from 'next/navigation' import Link from 'next/link' import { createClient } from '@/lib/supabase/server' import ProjectForm from '@/components/ProjectForm' +import Header from '@/components/Header' +import { ArrowLeft } from 'lucide-react' export default async function NewProjectPage() { const supabase = await createClient() @@ -29,12 +31,25 @@ export default async function NewProjectPage() { .eq('user_id', user.id) .single() - if (!userRole) { + // Check if user has organization assigned + if (!userRole || !userRole.organization_id) { return ( -
-
-

No organization assigned

-

Please contact an administrator to assign you to an organization.

+
+
+
+
+

No Organization Assigned

+

+ You need to be assigned to an organization before you can create projects. +

+ + + Back to Projects + +
) @@ -44,37 +59,30 @@ export default async function NewProjectPage() { const adminProjectRegion = process.env.ADMIN_PROJECT_REGION || 'us-east-1' return ( -
- {/* Header */} -
- - ← Back to Projects - -

Create New Project

-

- Create a new Supabase Pico project for your organization -

-
+
+
+
+ {/* Header */} +
+ + + Back to Projects + +

Create New Project

+

+ Create a new Supabase Pico project for your organization +

+
- {/* Info Box */} -
-

What you need to know

-
    -
  • • Projects are created on the Pico (free) tier
  • -
  • • Project provisioning typically completes in 1-2 minutes
  • -
  • • All projects include PostgreSQL database, Auth, Storage, and Realtime
  • -
  • • Projects will be created in the {adminProjectRegion} region
  • -
+ {/* Form */} +
- - {/* Form */} -
) } diff --git a/examples/supa-builder/app/projects/page.tsx b/examples/supa-builder/app/projects/page.tsx index f3da6792118..18f4f109313 100644 --- a/examples/supa-builder/app/projects/page.tsx +++ b/examples/supa-builder/app/projects/page.tsx @@ -10,6 +10,7 @@ import { redirect } from 'next/navigation' import { createClient } from '@/lib/supabase/server' import { getProjects } from '@/app/actions/projects' import ProjectTable from '@/components/ProjectTable' +import Header from '@/components/Header' export default async function ProjectsPage() { const supabase = await createClient() @@ -52,41 +53,27 @@ export default async function ProjectsPage() { } return ( -
- {/* Header */} -
-
-

Projects

-

- Manage your Supabase projects - {isAdmin && Admin} -

+
+
+
+ {/* Header */} +
+
+

Projects

+

+ Manage your Supabase projects +

+
+ + Create Project +
- - Create Project - -
- {/* User Info */} -
-

- Logged in as: {user.email} -

-
- - {/* Projects Table */} - - - {/* Footer Info */} -
-

About SupaBuilder

-

- SupaBuilder is an enterprise self-service platform for provisioning Supabase Pico projects. - All projects are automatically created with the free (Pico) tier and can be managed through this interface. -

+ {/* Projects Table */} +
) diff --git a/examples/supa-builder/components.json b/examples/supa-builder/components.json new file mode 100644 index 00000000000..05306cd37ec --- /dev/null +++ b/examples/supa-builder/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "app/globals.css", + "baseColor": "slate", + "cssVariables": true + }, + "iconLibrary": "lucide", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + }, + "registries": { + "@supabase": "https://supabase.com/ui/r/{name}.json" + } +} diff --git a/examples/supa-builder/components/CreatingProjectModal.tsx b/examples/supa-builder/components/CreatingProjectModal.tsx new file mode 100644 index 00000000000..23703facbd3 --- /dev/null +++ b/examples/supa-builder/components/CreatingProjectModal.tsx @@ -0,0 +1,38 @@ +'use client' + +import { Loader2 } from 'lucide-react' + +export default function CreatingProjectModal() { + return ( +
+
+ {/* Spinner */} +
+ +
+ + {/* Main Message */} +

+ Your new database is being created +

+ + {/* Info Messages */} +
+

+ When launched, you will be able to use a fully functional Postgres database, storage buckets for media, and authentication for sign in / sign up. +

+

+ You will be given a project reference URL and anonymous key. You can provide these to any AI Builder to start building with Supabase. +

+
+ + {/* Loading Indicator */} +
+

+ This usually takes 1-2 minutes... +

+
+
+
+ ) +} diff --git a/examples/supa-builder/components/DatabaseAccessModal.tsx b/examples/supa-builder/components/DatabaseAccessModal.tsx new file mode 100644 index 00000000000..75cc9ed043c --- /dev/null +++ b/examples/supa-builder/components/DatabaseAccessModal.tsx @@ -0,0 +1,52 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Button } from '@/components/ui/button' +import SupabaseManagerDialog from '@/components/supabase-manager' +import { Database } from 'lucide-react' + +interface DatabaseAccessModalProps { + projectRef: string + anonKey: string + projectName: string +} + +export default function DatabaseAccessModal({ + projectRef, + anonKey, + projectName, +}: DatabaseAccessModalProps) { + const [open, setOpen] = useState(false) + const [isMobile, setIsMobile] = useState(false) + + // Detect mobile viewport + useEffect(() => { + const checkMobile = () => { + setIsMobile(window.innerWidth < 768) + } + + checkMobile() + window.addEventListener('resize', checkMobile) + return () => window.removeEventListener('resize', checkMobile) + }, []) + + return ( + <> + + + + + ) +} diff --git a/examples/supa-builder/components/DeleteProjectButton.tsx b/examples/supa-builder/components/DeleteProjectButton.tsx new file mode 100644 index 00000000000..62979d62795 --- /dev/null +++ b/examples/supa-builder/components/DeleteProjectButton.tsx @@ -0,0 +1,89 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { deleteProject } from '@/app/actions/projects' +import { Trash2, Loader2 } from 'lucide-react' + +interface DeleteProjectButtonProps { + projectId: string + projectName: string +} + +export default function DeleteProjectButton({ projectId, projectName }: DeleteProjectButtonProps) { + const router = useRouter() + const [isDeleting, setIsDeleting] = useState(false) + const [showConfirm, setShowConfirm] = useState(false) + + const handleDelete = async () => { + setIsDeleting(true) + + const result = await deleteProject(projectId) + + if (!result.success) { + alert(`Failed to delete project: ${result.error}`) + setIsDeleting(false) + setShowConfirm(false) + return + } + + // Success - redirect to projects list + router.push('/projects') + router.refresh() + } + + if (showConfirm) { + return ( +
+
+
+ +
+
+

Delete Project

+

+ Are you sure you want to delete {projectName}? + This will permanently delete the project from Supabase and cannot be undone. +

+
+ + +
+
+
+
+ ) + } + + return ( + + ) +} diff --git a/examples/supa-builder/components/Header.tsx b/examples/supa-builder/components/Header.tsx new file mode 100644 index 00000000000..2a28f2393de --- /dev/null +++ b/examples/supa-builder/components/Header.tsx @@ -0,0 +1,64 @@ +'use client' + +import { createClient } from '@/lib/supabase/client' +import { useRouter } from 'next/navigation' +import { LogOut, User } from 'lucide-react' +import { useState } from 'react' + +interface HeaderProps { + userEmail?: string + userRole?: string +} + +export default function Header({ userEmail, userRole }: HeaderProps) { + const router = useRouter() + const [isLoggingOut, setIsLoggingOut] = useState(false) + + const handleLogout = async () => { + setIsLoggingOut(true) + const supabase = createClient() + await supabase.auth.signOut() + router.push('/login') + router.refresh() + } + + if (!userEmail) return null + + return ( +
+
+
+
+
SupaBuilder
+
+ +
+
+ + {userEmail} + {userRole && ( + + {userRole} + + )} +
+ +
+
+
+
+ ) +} diff --git a/examples/supa-builder/components/LoginButton.tsx b/examples/supa-builder/components/LoginButton.tsx index 4a886d6645a..dcf83372897 100644 --- a/examples/supa-builder/components/LoginButton.tsx +++ b/examples/supa-builder/components/LoginButton.tsx @@ -18,23 +18,23 @@ interface LoginButtonProps { const providerConfig = { google: { name: 'Google', - icon: '🔐', - color: 'bg-white hover:bg-gray-50 text-gray-900 border border-gray-300', + icon: '🔒', + color: 'bg-transparent hover:bg-[#2a2a2a] text-white border border-[#3a3a3a]', }, azure: { name: 'Azure AD', icon: '🔷', - color: 'bg-blue-600 hover:bg-blue-700 text-white', + color: 'bg-transparent hover:bg-[#2a2a2a] text-white border border-[#3a3a3a]', }, github: { name: 'GitHub', icon: '⚫', - color: 'bg-gray-800 hover:bg-gray-900 text-white', + color: 'bg-transparent hover:bg-[#2a2a2a] text-white border border-[#3a3a3a]', }, gitlab: { name: 'GitLab', icon: '🦊', - color: 'bg-orange-600 hover:bg-orange-700 text-white', + color: 'bg-transparent hover:bg-[#2a2a2a] text-white border border-[#3a3a3a]', }, } @@ -75,14 +75,14 @@ export default function LoginButton({ provider }: LoginButtonProps) { {error && ( -

+

{error}

)} diff --git a/examples/supa-builder/components/PlatformKitEmbed.tsx b/examples/supa-builder/components/PlatformKitEmbed.tsx new file mode 100644 index 00000000000..1d617c4a4b6 --- /dev/null +++ b/examples/supa-builder/components/PlatformKitEmbed.tsx @@ -0,0 +1,186 @@ +'use client' + +import { useEffect, useState, ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { DatabaseManager } from '@/components/supabase-manager/database' +import { StorageManager } from '@/components/supabase-manager/storage' +import { AuthManager } from '@/components/supabase-manager/auth' +import { UsersManager } from '@/components/supabase-manager/users' +import { SecretsManager } from '@/components/supabase-manager/secrets' +import { LogsManager } from '@/components/supabase-manager/logs' +import { SuggestionsManager } from '@/components/supabase-manager/suggestions' +import { + SheetNavigationProvider, + useSheetNavigation, +} from '@/contexts/SheetNavigationContext' +import { + ChevronLeft, + ChevronRight, + Database, + HardDrive, + Shield, + Users, + KeyRound, + ScrollText, + Lightbulb, +} from 'lucide-react' +import { Button } from '@/components/ui/button' + +const queryClient = new QueryClient() + +interface PlatformKitEmbedProps { + projectRef: string +} + +function PlatformKitContent({ projectRef }: { projectRef: string }) { + const { stack, push, popTo, reset } = useSheetNavigation() + + const handleTopLevelNavigation = (title: string, component: ReactNode) => { + if (stack.length === 1 && stack[0].title === title) { + return + } + reset() + push({ title, component }) + } + + const currentView = stack[stack.length - 1] + const activeManager = stack.length > 0 ? stack[0].title : null + + const navigationItems = [ + { + title: 'Database', + icon: Database, + component: , + }, + { + title: 'Storage', + icon: HardDrive, + component: , + }, + { + title: 'Auth', + icon: Shield, + component: , + }, + { + title: 'Users', + icon: Users, + component: , + }, + { + title: 'Secrets', + icon: KeyRound, + component: , + }, + { + title: 'Logs', + icon: ScrollText, + component: , + }, + { + title: 'Suggestions', + icon: Lightbulb, + component: , + }, + ] + + return ( +
+ {/* Sidebar */} +
+
+

+ Manage Backend +

+
+
+ {navigationItems.map((item) => { + const Icon = item.icon + return ( + + ) + })} +
+
+ + {/* Content Area */} +
+ {/* Header with breadcrumbs */} +
+ {stack.length > 1 && ( + + )} + {/* Breadcrumbs */} +
+ {stack.map((item: { title: string }, index: number) => ( +
+ {index > 0 && } + {index === stack.length - 1 ? ( + {item.title} + ) : ( + + )} +
+ ))} +
+
+ +
+ {currentView ? ( + currentView.component + ) : ( +
+

+ Select a manager from the sidebar to get started. +

+
+ )} +
+
+
+ ) +} + +export default function PlatformKitEmbed({ projectRef }: PlatformKitEmbedProps) { + return ( +
+ + {}} + initialStack={[ + { + title: 'Database', + component: , + }, + ]} + > + + + +
+ ) +} diff --git a/examples/supa-builder/components/ProjectForm.tsx b/examples/supa-builder/components/ProjectForm.tsx index e8f654b65cc..7640f6b7ebf 100644 --- a/examples/supa-builder/components/ProjectForm.tsx +++ b/examples/supa-builder/components/ProjectForm.tsx @@ -10,6 +10,7 @@ import { useState } from 'react' import { useRouter } from 'next/navigation' import { createProject, type CreateProjectInput } from '@/app/actions/projects' +import CreatingProjectModal from './CreatingProjectModal' const REGIONS = [ { value: 'us-east-1', label: 'US East (N. Virginia)' }, @@ -33,6 +34,8 @@ export default function ProjectForm({ defaultOrganizationId, defaultRegion }: Pr const router = useRouter() const [isSubmitting, setIsSubmitting] = useState(false) const [error, setError] = useState(null) + const [errorDetails, setErrorDetails] = useState(null) + const [showErrorDetails, setShowErrorDetails] = useState(false) const [formData, setFormData] = useState({ project_name: '', organization_id: defaultOrganizationId || '', @@ -44,6 +47,8 @@ export default function ProjectForm({ defaultOrganizationId, defaultRegion }: Pr const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setError(null) + setErrorDetails(null) + setShowErrorDetails(false) setIsSubmitting(true) try { @@ -60,19 +65,51 @@ export default function ProjectForm({ defaultOrganizationId, defaultRegion }: Pr return } + console.log('[ProjectForm] Submitting project creation request:', { + project_name: formData.project_name, + organization_id: formData.organization_id, + region: formData.region, + timestamp: new Date().toISOString(), + }) + // Call server action const result = await createProject(formData) + console.log('[ProjectForm] Received response from server action:', { + success: result.success, + hasError: !!result.error, + hasErrorDetails: !!result.errorDetails, + timestamp: new Date().toISOString(), + }) + if (!result.success) { - setError(result.error || 'Failed to create project') + const errorMessage = result.error || 'Failed to create project' + setError(errorMessage) + + if (result.errorDetails && Object.keys(result.errorDetails).length > 0) { + setErrorDetails(result.errorDetails) + console.error('[ProjectForm] Error details:', result.errorDetails) + } else { + console.error('[ProjectForm] Error:', errorMessage) + } + setIsSubmitting(false) return } - // Success - redirect to projects list - router.push('/projects') + // Success - redirect to projects page + console.log('[ProjectForm] Project created successfully, redirecting to projects page...') + console.log('[ProjectForm] Project data:', result.data) + + // Keep spinner showing briefly then redirect + setTimeout(() => { + router.push('/projects') + router.refresh() + }, 2000) } catch (err) { + console.error('[ProjectForm] Unexpected error during submission:', err) setError(err instanceof Error ? err.message : 'An unexpected error occurred') + setErrorDetails({ unexpectedError: true, message: String(err) }) setIsSubmitting(false) } } @@ -85,19 +122,87 @@ export default function ProjectForm({ defaultOrganizationId, defaultRegion }: Pr } return ( -
- {/* Error Display */} + <> + {/* Creating Project Modal */} + {isSubmitting && } + + + {/* Error Display */} {error && ( -
+

Error

-

{error}

+

{error}

+ + {/* Technical Details */} + {errorDetails && ( +
+ + + {showErrorDetails && ( +
+
+ {errorDetails.statusCode && ( +
+ HTTP Status: {errorDetails.statusCode}{' '} + {errorDetails.statusText} +
+ )} + {errorDetails.requestUrl && ( +
+ Request URL: {errorDetails.requestUrl} +
+ )} + {errorDetails.responseBody && ( +
+ Response: +
+                          {typeof errorDetails.responseBody === 'string'
+                            ? errorDetails.responseBody
+                            : JSON.stringify(errorDetails.responseBody, null, 2)}
+                        
+
+ )} +
+
+

Debugging Tips:

+
    +
  • Check browser console (F12) for detailed logs
  • +
  • Check Supabase Edge Function logs in dashboard
  • +
  • Verify your authentication token is valid
  • + {errorDetails.statusCode === 429 && ( +
  • + Rate limit exceeded. Wait before creating more projects. +
  • + )} + {errorDetails.statusCode === 401 && ( +
  • + Authentication failed. Try logging out and back in. +
  • + )} + {errorDetails.statusCode >= 500 && ( +
  • + Server error. Check Edge Function logs for details. +
  • + )} +
+
+
+ )} +
+ )}
)} {/* Project Name */} -
-