Supabase UI library, delete, squash migrations

This commit is contained in:
Chris Caruso committed 2026-02-03 12:23:37 -06:00
1 parent b78d05b89f
commit c9989efef9
83 files changed
+18907 -1820

No files matched your search

+2
View File
@@ -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
+3 -3
View File
@@ -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
+160 -28
View File
@@ -42,6 +42,12 @@ export interface ActionResult<T = unknown> {
success: boolean
data?: T
error?: string
errorDetails?: {
statusCode?: number
statusText?: string
requestUrl?: string
responseBody?: unknown
}
}
// ============================================================================
@@ -51,6 +57,8 @@ export interface ActionResult<T = unknown> {
export async function createProject(
input: CreateProjectInput
): Promise<ActionResult<{ project_id: string; project_ref: string; anon_key: string }>> {
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<ActionResult> {
// ============================================================================
export async function deleteProject(projectId: string): Promise<ActionResult> {
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<ActionResult> {
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',
@@ -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<paths>({
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 })
}
}
@@ -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)
}
+2
View File
@@ -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}
<Toaster position="top-right" richColors />
</body>
</html>
);
+8 -33
View File
@@ -23,45 +23,20 @@ export default async function LoginPage() {
}
return (
<div className="flex min-h-screen flex-col items-center justify-center p-8 bg-gradient-to-b from-gray-50 to-white">
<div className="w-full max-w-md space-y-8">
<div className="min-h-screen bg-[#1a1a1a] flex items-center justify-center px-8">
<div className="w-full max-w-md">
{/* Header */}
<div className="text-center">
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent mb-2">
<div className="text-center mb-12">
<h1 className="text-5xl font-bold text-white mb-6">
SupaBuilder
</h1>
<p className="text-gray-600">
Sign in to manage your Supabase projects
<p className="text-[#a0a0a0] text-base leading-relaxed">
Enable your team to create and manage Supabase projects with proper governance, audit trails, and role-based access control.
</p>
</div>
{/* Login Card */}
<div className="bg-white border border-gray-200 rounded-lg shadow-lg p-8">
<h2 className="text-2xl font-semibold mb-6 text-center">
Sign In
</h2>
{/* SSO Login Buttons */}
<div className="space-y-4">
<LoginButton provider="google" />
{/* Add more providers as needed */}
{/* <LoginButton provider="azure" /> */}
{/* <LoginButton provider="github" /> */}
</div>
{/* Info Text */}
<div className="mt-6 text-center text-sm text-gray-500">
<p>Sign in with your organization account</p>
</div>
</div>
{/* Footer Info */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-800 text-center">
<strong>First-time users:</strong> The first person to sign in from your organization will automatically receive admin privileges.
</p>
</div>
{/* Sign In Button */}
<LoginButton provider="google" />
</div>
</div>
)
+63 -150
View File
@@ -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 (
<span
className={`px-3 py-1 rounded-full text-sm font-medium border ${
className={`px-2.5 py-1 rounded-full text-xs font-medium border ${
styles[status] || styles.active
}`}
>
@@ -74,151 +71,67 @@ export default async function ProjectDetailPage({ params }: ProjectDetailPagePro
}
return (
<div className="container mx-auto px-4 py-8 max-w-6xl">
{/* Header */}
<div className="mb-8">
<Link
href="/projects"
className="text-blue-600 hover:text-blue-800 text-sm mb-4 inline-block"
>
← Back to Projects
</Link>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold mb-2">{project.project_name}</h1>
<p className="text-gray-600">Project Reference: {project.project_ref}</p>
</div>
{getStatusBadge(project.status)}
</div>
</div>
{/* Project Details Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
{/* Basic Information */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Basic Information</h2>
<dl className="space-y-3">
<div className="min-h-screen bg-[#0E0E0E]">
<Header userEmail={user.email} userRole={isAdmin ? 'admin' : 'builder'} />
<div className="container mx-auto px-6 py-8 max-w-[1600px]">
{/* Header */}
<div className="mb-6">
<Link
href="/projects"
className="inline-flex items-center gap-2 text-[#808080] hover:text-[#D0D0D0] text-sm mb-4 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to Projects
</Link>
<div className="flex items-center justify-between">
<div>
<dt className="text-sm font-medium text-gray-500">Organization ID</dt>
<dd className="text-sm text-gray-900">{project.organization_id}</dd>
<h1 className="text-3xl font-medium text-white mb-1">{project.project_name}</h1>
<p className="text-[#808080] text-sm font-mono">{project.project_ref}</p>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Region</dt>
<dd className="text-sm text-gray-900">{project.region}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Created By</dt>
<dd className="text-sm text-gray-900">{project.creator_email}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Created At</dt>
<dd className="text-sm text-gray-900">
{new Date(project.created_at).toLocaleString()}
</dd>
</div>
{project.purpose && (
<div>
<dt className="text-sm font-medium text-gray-500">Purpose</dt>
<dd className="text-sm text-gray-900">{project.purpose}</dd>
</div>
)}
</dl>
</div>
{/* Credentials */}
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">API Credentials</h2>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-gray-500 block mb-1">
Supabase URL
</label>
<code className="block text-xs bg-gray-100 px-3 py-2 rounded border border-gray-200 break-all">
https://{project.project_ref}.supabase.co
</code>
</div>
<div>
<label className="text-sm font-medium text-gray-500 block mb-1">
Anon Key
</label>
<code className="block text-xs bg-gray-100 px-3 py-2 rounded border border-gray-200 break-all">
{project.anon_key}
</code>
</div>
{isAdmin && (
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 mt-4">
<p className="text-xs text-yellow-800">
<strong>Admin Note:</strong> Service role key is encrypted in the database.
Contact system administrator for access.
</p>
</div>
)}
{getStatusBadge(project.status)}
</div>
</div>
</div>
{/* Description */}
{project.description && (
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-8">
<h2 className="text-lg font-semibold mb-4">Description</h2>
<p className="text-sm text-gray-700 whitespace-pre-wrap">{project.description}</p>
{/* Project Info - Collapsible */}
<div className="mb-6">
<ProjectInfo project={project} />
</div>
)}
{/* Quick Links */}
<div className="bg-white border border-gray-200 rounded-lg p-6 mb-8">
<h2 className="text-lg font-semibold mb-4">Quick Links</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<a
href={`https://supabase.com/dashboard/project/${project.project_ref}`}
target="_blank"
rel="noopener noreferrer"
className="text-center px-4 py-3 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-blue-600">Dashboard →</span>
</a>
<a
href={`https://supabase.com/dashboard/project/${project.project_ref}/editor`}
target="_blank"
rel="noopener noreferrer"
className="text-center px-4 py-3 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-blue-600">Table Editor →</span>
</a>
<a
href={`https://supabase.com/dashboard/project/${project.project_ref}/auth/users`}
target="_blank"
rel="noopener noreferrer"
className="text-center px-4 py-3 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<span className="text-sm font-medium text-blue-600">Auth Users →</span>
</a>
</div>
</div>
{/* Admin Actions */}
{isAdmin && project.status !== 'deleted' && (
<div className="mb-6">
<DeleteProjectButton
projectId={project.id}
projectName={project.project_name}
/>
</div>
)}
{/* Audit Logs */}
{auditLogs && auditLogs.length > 0 && (
<div className="bg-white border border-gray-200 rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Recent Activity</h2>
<div className="space-y-3">
{auditLogs.map((log) => (
<div key={log.id} className="flex items-start gap-3 text-sm border-b border-gray-100 pb-3 last:border-0">
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded text-xs font-medium">
{log.action}
</span>
<div className="flex-1">
<p className="text-gray-900">
{log.actor_email}
</p>
<p className="text-xs text-gray-500">
{new Date(log.created_at).toLocaleString()}
</p>
{/* Platform Kit UI */}
{project.status === 'active' ? (
<PlatformKitEmbed projectRef={project.project_ref} />
) : (
<div className="bg-[#1C1C1C] border border-[#2C2C2C] rounded-lg p-12 text-center">
<div className="max-w-md mx-auto">
<h2 className="text-xl font-medium text-white mb-2">
{project.status === 'provisioning' && 'Project Provisioning'}
{project.status === 'paused' && 'Project Paused'}
{project.status === 'failed' && 'Provisioning Failed'}
</h2>
<p className="text-[#A0A0A0] mb-6">
{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.'}
</p>
{project.status === 'provisioning' && (
<div className="flex justify-center">
<div className="w-8 h-8 border-2 border-[#3ECF8E] border-t-transparent rounded-full animate-spin"></div>
</div>
</div>
))}
)}
</div>
</div>
</div>
)}
)}
</div>
</div>
)
}
@@ -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<Project | null>(null)
const [error, setError] = useState<string | null>(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 (
<div className="min-h-screen bg-gradient-to-br from-red-50 to-orange-50 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center">
<div className="flex justify-center mb-4">
<AlertCircle className="w-16 h-16 text-red-500" />
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Provisioning Failed</h1>
<p className="text-gray-600 mb-6">{error}</p>
<button
onClick={() => router.push('/projects')}
className="px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
>
Back to Projects
</button>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-indigo-50 to-purple-50 flex items-center justify-center p-4">
<div className="max-w-2xl w-full bg-white rounded-lg shadow-lg p-8">
{/* Header */}
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<Loader2 className="w-16 h-16 text-blue-600 animate-spin" />
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Creating Your Project{dots}
</h1>
{project && (
<p className="text-lg text-gray-600">
{project.project_name}
</p>
)}
</div>
{/* Progress Steps */}
<div className="space-y-4">
<ProvisioningStep
icon={<CheckCircle2 className="w-5 h-5 text-green-600" />}
title="Project Registered"
description="Your project has been registered in the system"
completed={true}
/>
<ProvisioningStep
icon={<Loader2 className="w-5 h-5 text-blue-600 animate-spin" />}
title="Infrastructure Provisioning"
description="Setting up database, storage, and authentication services"
completed={false}
inProgress={true}
/>
<ProvisioningStep
icon={<Clock className="w-5 h-5 text-gray-400" />}
title="Finalizing Setup"
description="Configuring API keys and network settings"
completed={false}
/>
</div>
{/* Info Box */}
<div className="mt-8 bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-800 text-center">
This usually takes 1-2 minutes. You'll be automatically redirected when ready.
</p>
</div>
{/* Manual navigation */}
<div className="mt-6 text-center">
<button
onClick={() => router.push('/projects')}
className="text-sm text-gray-500 hover:text-gray-700 underline"
>
Return to projects list
</button>
</div>
</div>
</div>
)
}
function ProvisioningStep({
icon,
title,
description,
completed,
inProgress = false,
}: {
icon: React.ReactNode
title: string
description: string
completed: boolean
inProgress?: boolean
}) {
return (
<div className="flex items-start gap-4 p-4 rounded-lg bg-gray-50 border border-gray-200">
<div className="flex-shrink-0 mt-0.5">
{icon}
</div>
<div className="flex-1">
<h3 className={`font-semibold ${
completed ? 'text-green-900' : inProgress ? 'text-blue-900' : 'text-gray-500'
}`}>
{title}
</h3>
<p className={`text-sm ${
completed ? 'text-green-700' : inProgress ? 'text-blue-700' : 'text-gray-500'
}`}>
{description}
</p>
</div>
</div>
)
}
+42 -34
View File
@@ -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 (
<div className="container mx-auto px-4 py-8">
<div className="bg-red-50 border border-red-200 text-red-800 rounded-lg p-4">
<p className="font-medium">No organization assigned</p>
<p className="text-sm">Please contact an administrator to assign you to an organization.</p>
<div className="min-h-screen bg-[#0E0E0E]">
<Header userEmail={user.email} userRole={userRole?.role} />
<div className="container mx-auto px-6 py-12 max-w-2xl">
<div className="bg-red-500/10 border border-red-500/20 text-red-500 rounded-lg p-6 text-center">
<h2 className="text-xl font-medium mb-2">No Organization Assigned</h2>
<p className="text-sm text-red-400 mb-4">
You need to be assigned to an organization before you can create projects.
</p>
<Link
href="/projects"
className="inline-flex items-center gap-2 text-sm text-[#3ECF8E] hover:text-[#3ECF8E]/80"
>
<ArrowLeft className="w-4 h-4" />
Back to Projects
</Link>
</div>
</div>
</div>
)
@@ -44,37 +59,30 @@ export default async function NewProjectPage() {
const adminProjectRegion = process.env.ADMIN_PROJECT_REGION || 'us-east-1'
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
{/* Header */}
<div className="mb-8">
<Link
href="/projects"
className="text-blue-600 hover:text-blue-800 text-sm mb-4 inline-block"
>
← Back to Projects
</Link>
<h1 className="text-3xl font-bold mb-2">Create New Project</h1>
<p className="text-gray-600">
Create a new Supabase Pico project for your organization
</p>
</div>
<div className="min-h-screen bg-[#0E0E0E]">
<Header userEmail={user.email} userRole={userRole.role} />
<div className="container mx-auto px-6 py-12 max-w-3xl">
{/* Header */}
<div className="mb-10">
<Link
href="/projects"
className="inline-flex items-center gap-2 text-[#808080] hover:text-[#D0D0D0] text-sm mb-6 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
Back to Projects
</Link>
<h1 className="text-4xl font-medium text-white mb-2">Create New Project</h1>
<p className="text-[#A0A0A0] text-base">
Create a new Supabase Pico project for your organization
</p>
</div>
{/* Info Box */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<h3 className="font-medium text-blue-900 mb-2">What you need to know</h3>
<ul className="text-sm text-blue-800 space-y-1">
<li>• Projects are created on the Pico (free) tier</li>
<li>• Project provisioning typically completes in 1-2 minutes</li>
<li>• All projects include PostgreSQL database, Auth, Storage, and Realtime</li>
<li>• Projects will be created in the {adminProjectRegion} region</li>
</ul>
{/* Form */}
<ProjectForm
defaultOrganizationId={userRole.organization_id}
defaultRegion={adminProjectRegion}
/>
</div>
{/* Form */}
<ProjectForm
defaultOrganizationId={userRole.organization_id}
defaultRegion={adminProjectRegion}
/>
</div>
)
}
+20 -33
View File
@@ -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 (
<div className="container mx-auto px-4 py-8 max-w-7xl">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-3xl font-bold mb-2">Projects</h1>
<p className="text-gray-600">
Manage your Supabase projects
{isAdmin && <span className="ml-2 px-2 py-1 bg-blue-100 text-blue-800 text-xs font-medium rounded">Admin</span>}
</p>
<div className="min-h-screen bg-[#0E0E0E]">
<Header userEmail={user.email} userRole={isAdmin ? 'admin' : 'builder'} />
<div className="container mx-auto px-6 py-12 max-w-7xl">
{/* Header */}
<div className="flex items-center justify-between mb-10">
<div>
<h1 className="text-4xl font-medium text-white mb-2">Projects</h1>
<p className="text-[#A0A0A0] text-base">
Manage your Supabase projects
</p>
</div>
<Link
href="/projects/new"
className="px-5 py-2.5 bg-[#3ECF8E] text-[#0E0E0E] rounded-md hover:bg-[#3ECF8E]/90 font-medium transition-colors text-sm"
>
Create Project
</Link>
</div>
<Link
href="/projects/new"
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
>
Create Project
</Link>
</div>
{/* User Info */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
<p className="text-sm text-gray-700">
Logged in as: <strong>{user.email}</strong>
</p>
</div>
{/* Projects Table */}
<ProjectTable projects={projects} isAdmin={isAdmin} />
{/* Footer Info */}
<div className="mt-8 bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 className="font-medium text-blue-900 mb-2">About SupaBuilder</h3>
<p className="text-sm text-blue-800">
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.
</p>
{/* Projects Table */}
<ProjectTable projects={projects} isAdmin={isAdmin} />
</div>
</div>
)
+20
View File
@@ -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"
}
}
@@ -0,0 +1,38 @@
'use client'
import { Loader2 } from 'lucide-react'
export default function CreatingProjectModal() {
return (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-[#1C1C1C] border border-[#2C2C2C] rounded-lg p-12 max-w-2xl w-full text-center">
{/* Spinner */}
<div className="flex justify-center mb-8">
<Loader2 className="w-16 h-16 text-[#3ECF8E] animate-spin" />
</div>
{/* Main Message */}
<h2 className="text-3xl font-medium text-white mb-6">
Your new database is being created
</h2>
{/* Info Messages */}
<div className="space-y-4 text-[#A0A0A0] text-base max-w-xl mx-auto">
<p>
When launched, you will be able to use a fully functional Postgres database, storage buckets for media, and authentication for sign in / sign up.
</p>
<p>
You will be given a project reference URL and anonymous key. You can provide these to any AI Builder to start building with Supabase.
</p>
</div>
{/* Loading Indicator */}
<div className="mt-8 pt-6 border-t border-[#2C2C2C]">
<p className="text-sm text-[#808080]">
This usually takes 1-2 minutes...
</p>
</div>
</div>
</div>
)
}
@@ -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 (
<>
<Button
onClick={() => setOpen(true)}
className="px-6 py-3 bg-green-600 text-white hover:bg-green-700 transition-colors"
size="lg"
>
<Database className="mr-2 h-5 w-5" />
Access Database
</Button>
<SupabaseManagerDialog
projectRef={projectRef}
open={open}
onOpenChange={setOpen}
isMobile={isMobile}
/>
</>
)
}
@@ -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 (
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-6">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-red-500/20 flex items-center justify-center">
<Trash2 className="w-6 h-6 text-red-500" />
</div>
<div className="flex-1">
<h3 className="text-lg font-medium text-white mb-2">Delete Project</h3>
<p className="text-sm text-[#A0A0A0] mb-4">
Are you sure you want to delete <span className="text-white font-medium">{projectName}</span>?
This will permanently delete the project from Supabase and cannot be undone.
</p>
<div className="flex gap-3">
<button
onClick={handleDelete}
disabled={isDeleting}
className="px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors flex items-center gap-2"
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Deleting...
</>
) : (
<>
<Trash2 className="w-4 h-4" />
Delete Project
</>
)}
</button>
<button
onClick={() => setShowConfirm(false)}
disabled={isDeleting}
className="px-4 py-2 bg-transparent border border-[#2C2C2C] text-[#D0D0D0] rounded-md hover:bg-[#2C2C2C] disabled:opacity-50 font-medium transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
</div>
)
}
return (
<button
onClick={() => setShowConfirm(true)}
className="flex items-center gap-2 px-4 py-2 bg-red-500/10 border border-red-500/20 text-red-500 rounded-md hover:bg-red-500/20 transition-colors"
>
<Trash2 className="w-4 h-4" />
Delete Project
</button>
)
}
@@ -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 (
<header className="sticky top-0 z-50 border-b border-[#2C2C2C] bg-[#0E0E0E]/95 backdrop-blur supports-[backdrop-filter]:bg-[#0E0E0E]/80">
<div className="container mx-auto px-6">
<div className="flex h-14 items-center justify-between">
<div className="flex items-center gap-2">
<div className="text-xl font-semibold text-white">SupaBuilder</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-3 text-sm text-[#A0A0A0]">
<User className="w-4 h-4" />
<span>{userEmail}</span>
{userRole && (
<span
className={`px-2 py-0.5 rounded text-xs font-medium ${
userRole === 'admin'
? 'bg-[#3ECF8E]/10 text-[#3ECF8E] border border-[#3ECF8E]/20'
: 'bg-blue-500/10 text-blue-400 border border-blue-500/20'
}`}
>
{userRole}
</span>
)}
</div>
<button
onClick={handleLogout}
disabled={isLoggingOut}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-[#A0A0A0] hover:text-white hover:bg-[#2C2C2C] rounded-md transition-colors disabled:opacity-50"
>
<LogOut className="w-4 h-4" />
{isLoggingOut ? 'Logging out...' : 'Logout'}
</button>
</div>
</div>
</div>
</header>
)
}
@@ -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) {
<button
onClick={handleLogin}
disabled={isLoading}
className={`w-full px-6 py-3 rounded-lg font-medium transition-all shadow-sm disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-3 ${config.color}`}
className={`w-full px-6 py-3 rounded-md font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-3 ${config.color}`}
>
<span className="text-xl">{config.icon}</span>
{config.icon && <span className="text-lg">{config.icon}</span>}
{isLoading ? `Signing in...` : `Continue with ${config.name}`}
</button>
{error && (
<p className="mt-2 text-sm text-red-600">
<p className="mt-3 text-sm text-red-400">
{error}
</p>
)}
@@ -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: <DatabaseManager projectRef={projectRef} />,
},
{
title: 'Storage',
icon: HardDrive,
component: <StorageManager projectRef={projectRef} />,
},
{
title: 'Auth',
icon: Shield,
component: <AuthManager projectRef={projectRef} />,
},
{
title: 'Users',
icon: Users,
component: <UsersManager projectRef={projectRef} />,
},
{
title: 'Secrets',
icon: KeyRound,
component: <SecretsManager projectRef={projectRef} />,
},
{
title: 'Logs',
icon: ScrollText,
component: <LogsManager projectRef={projectRef} />,
},
{
title: 'Suggestions',
icon: Lightbulb,
component: <SuggestionsManager projectRef={projectRef} />,
},
]
return (
<div className="grid grid-cols-[200px_1fr] h-full overflow-hidden">
{/* Sidebar */}
<div className="flex flex-col border-r border-[#2C2C2C] px-3 py-6 pb-3 bg-[#1C1C1C]">
<div className="px-3 mb-4">
<h2 className="text-[#808080] font-medium text-xs uppercase tracking-wide">
Manage Backend
</h2>
</div>
<div className="grow space-y-0.5">
{navigationItems.map((item) => {
const Icon = item.icon
return (
<Button
key={item.title}
variant={activeManager === item.title ? 'secondary' : 'ghost'}
className={`w-full justify-start text-sm ${
activeManager === item.title
? 'bg-[#2C2C2C] text-white'
: 'text-[#A0A0A0] hover:text-white hover:bg-[#2C2C2C]/50'
}`}
onClick={() => handleTopLevelNavigation(item.title, item.component)}
>
<Icon className="mr-2 w-4 h-4" />
{item.title === 'Auth' ? 'Authentication' : item.title}
</Button>
)
})}
</div>
</div>
{/* Content Area */}
<div className="flex flex-col overflow-hidden h-full bg-[#0E0E0E]">
{/* Header with breadcrumbs */}
<div className="flex items-center h-12 shrink-0 px-4 border-b border-[#2C2C2C]">
{stack.length > 1 && (
<Button
variant="outline"
size="icon"
className="h-8 w-8 border-[#2C2C2C] bg-transparent hover:bg-[#2C2C2C]"
onClick={() => popTo(stack.length - 2)}
>
<ChevronLeft className="h-4 w-4 text-[#A0A0A0]" />
</Button>
)}
{/* Breadcrumbs */}
<div className="ml-4 flex items-center gap-1.5 text-sm text-[#A0A0A0]">
{stack.map((item: { title: string }, index: number) => (
<div key={`${item.title}-${index}`} className="flex items-center gap-1.5">
{index > 0 && <ChevronRight className="h-3 w-3" />}
{index === stack.length - 1 ? (
<span className="font-medium text-white">{item.title}</span>
) : (
<button onClick={() => popTo(index)} className="hover:text-white">
{item.title}
</button>
)}
</div>
))}
</div>
</div>
<div className="grow overflow-y-auto">
{currentView ? (
currentView.component
) : (
<div className="flex items-center justify-center h-full">
<p className="text-[#808080]">
Select a manager from the sidebar to get started.
</p>
</div>
)}
</div>
</div>
</div>
)
}
export default function PlatformKitEmbed({ projectRef }: PlatformKitEmbedProps) {
return (
<div
className="bg-[#1C1C1C] border border-[#2C2C2C] rounded-lg overflow-hidden"
style={{ height: 'calc(100vh - 280px)', minHeight: '600px' }}
>
<QueryClientProvider client={queryClient}>
<SheetNavigationProvider
onStackEmpty={() => {}}
initialStack={[
{
title: 'Database',
component: <DatabaseManager projectRef={projectRef} />,
},
]}
>
<PlatformKitContent projectRef={projectRef} />
</SheetNavigationProvider>
</QueryClientProvider>
</div>
)
}
+134 -37
View File
@@ -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<string | null>(null)
const [errorDetails, setErrorDetails] = useState<any>(null)
const [showErrorDetails, setShowErrorDetails] = useState(false)
const [formData, setFormData] = useState<CreateProjectInput>({
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 (
<form onSubmit={handleSubmit} className="space-y-6 max-w-2xl">
{/* Error Display */}
<>
{/* Creating Project Modal */}
{isSubmitting && <CreatingProjectModal />}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Error Display */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-800 rounded-lg p-4">
<div className="bg-red-500/10 border border-red-500/20 text-red-500 rounded-lg p-4">
<p className="font-medium">Error</p>
<p className="text-sm">{error}</p>
<p className="text-sm mt-1">{error}</p>
{/* Technical Details */}
{errorDetails && (
<div className="mt-3">
<button
type="button"
onClick={() => setShowErrorDetails(!showErrorDetails)}
className="text-xs font-medium text-red-700 hover:text-red-900 underline"
>
{showErrorDetails ? 'Hide' : 'Show'} Technical Details
</button>
{showErrorDetails && (
<div className="mt-2 p-3 bg-red-100 rounded border border-red-300 text-xs font-mono overflow-x-auto">
<div className="space-y-1">
{errorDetails.statusCode && (
<div>
<span className="font-bold">HTTP Status:</span> {errorDetails.statusCode}{' '}
{errorDetails.statusText}
</div>
)}
{errorDetails.requestUrl && (
<div>
<span className="font-bold">Request URL:</span> {errorDetails.requestUrl}
</div>
)}
{errorDetails.responseBody && (
<div>
<span className="font-bold">Response:</span>
<pre className="mt-1 whitespace-pre-wrap break-words">
{typeof errorDetails.responseBody === 'string'
? errorDetails.responseBody
: JSON.stringify(errorDetails.responseBody, null, 2)}
</pre>
</div>
)}
</div>
<div className="mt-2 pt-2 border-t border-red-400 text-red-700">
<p className="font-bold">Debugging Tips:</p>
<ul className="list-disc list-inside mt-1 space-y-1">
<li>Check browser console (F12) for detailed logs</li>
<li>Check Supabase Edge Function logs in dashboard</li>
<li>Verify your authentication token is valid</li>
{errorDetails.statusCode === 429 && (
<li className="text-red-900 font-bold">
Rate limit exceeded. Wait before creating more projects.
</li>
)}
{errorDetails.statusCode === 401 && (
<li className="text-red-900 font-bold">
Authentication failed. Try logging out and back in.
</li>
)}
{errorDetails.statusCode >= 500 && (
<li className="text-red-900 font-bold">
Server error. Check Edge Function logs for details.
</li>
)}
</ul>
</div>
</div>
)}
</div>
)}
</div>
)}
{/* Project Name */}
<div>
<label htmlFor="project_name" className="block text-sm font-medium mb-2">
Project Name *
<div className="bg-[#1C1C1C] border border-[#2C2C2C] rounded-lg p-6">
<label htmlFor="project_name" className="block text-sm font-medium text-white mb-2">
Project Name <span className="text-red-500">*</span>
</label>
<input
type="text"
@@ -109,10 +214,10 @@ export default function ProjectForm({ defaultOrganizationId, defaultRegion }: Pr
minLength={3}
maxLength={63}
placeholder="my-awesome-project"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="w-full px-4 py-2.5 bg-[#0E0E0E] border border-[#2C2C2C] text-white rounded-md focus:outline-none focus:ring-2 focus:ring-[#3ECF8E] focus:border-transparent placeholder:text-[#808080] disabled:opacity-50"
disabled={isSubmitting}
/>
<p className="text-xs text-gray-500 mt-1">
<p className="text-xs text-[#808080] mt-2">
3-63 characters, lowercase letters, numbers, and hyphens
</p>
</div>
@@ -122,9 +227,9 @@ export default function ProjectForm({ defaultOrganizationId, defaultRegion }: Pr
<input type="hidden" name="region" value={formData.region} />
{/* Purpose */}
<div>
<label htmlFor="purpose" className="block text-sm font-medium mb-2">
Purpose
<div className="bg-[#1C1C1C] border border-[#2C2C2C] rounded-lg p-6">
<label htmlFor="purpose" className="block text-sm font-medium text-white mb-2">
Purpose <span className="text-[#808080] text-xs">(Optional)</span>
</label>
<input
type="text"
@@ -132,19 +237,19 @@ export default function ProjectForm({ defaultOrganizationId, defaultRegion }: Pr
name="purpose"
value={formData.purpose}
onChange={handleInputChange}
placeholder="Development, Staging, Production, etc."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Development, Staging, Production"
className="w-full px-4 py-2.5 bg-[#0E0E0E] border border-[#2C2C2C] text-white rounded-md focus:outline-none focus:ring-2 focus:ring-[#3ECF8E] focus:border-transparent placeholder:text-[#808080] disabled:opacity-50"
disabled={isSubmitting}
/>
<p className="text-xs text-gray-500 mt-1">
Optional: What will this project be used for?
<p className="text-xs text-[#808080] mt-2">
What will this project be used for?
</p>
</div>
{/* Description */}
<div>
<label htmlFor="description" className="block text-sm font-medium mb-2">
Description
<div className="bg-[#1C1C1C] border border-[#2C2C2C] rounded-lg p-6">
<label htmlFor="description" className="block text-sm font-medium text-white mb-2">
Description <span className="text-[#808080] text-xs">(Optional)</span>
</label>
<textarea
id="description"
@@ -152,21 +257,18 @@ export default function ProjectForm({ defaultOrganizationId, defaultRegion }: Pr
value={formData.description}
onChange={handleInputChange}
rows={4}
placeholder="Describe your project..."
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Additional details about your project"
className="w-full px-4 py-2.5 bg-[#0E0E0E] border border-[#2C2C2C] text-white rounded-md focus:outline-none focus:ring-2 focus:ring-[#3ECF8E] focus:border-transparent placeholder:text-[#808080] disabled:opacity-50 resize-none"
disabled={isSubmitting}
/>
<p className="text-xs text-gray-500 mt-1">
Optional: Additional details about this project
</p>
</div>
{/* Submit Button */}
<div className="flex gap-4">
{/* Submit Buttons */}
<div className="flex gap-3 pt-4">
<button
type="submit"
disabled={isSubmitting}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"
className="px-6 py-2.5 bg-[#3ECF8E] text-[#0E0E0E] rounded-md hover:bg-[#3ECF8E]/90 disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors"
>
{isSubmitting ? 'Creating Project...' : 'Create Project'}
</button>
@@ -175,18 +277,13 @@ export default function ProjectForm({ defaultOrganizationId, defaultRegion }: Pr
type="button"
onClick={() => router.back()}
disabled={isSubmitting}
className="px-6 py-2 bg-gray-200 text-gray-800 rounded-lg hover:bg-gray-300 disabled:bg-gray-100 disabled:cursor-not-allowed font-medium"
className="px-6 py-2.5 bg-transparent border border-[#2C2C2C] text-[#D0D0D0] rounded-md hover:bg-[#2C2C2C] disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors"
>
Cancel
</button>
</div>
{/* Info */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-800">
Projects are provisioned as Pico (free) tier.
</p>
</div>
</form>
</>
)
}
@@ -0,0 +1,135 @@
'use client'
import { useState } from 'react'
import { ChevronDown, ChevronUp, Copy, Check } from 'lucide-react'
interface ProjectInfoProps {
project: {
project_name: string
project_ref: string
organization_id: string
region: string
creator_email: string
created_at: string
anon_key: string
purpose?: string
}
}
export default function ProjectInfo({ project }: ProjectInfoProps) {
const [isOpen, setIsOpen] = useState(false)
const [copiedUrl, setCopiedUrl] = useState(false)
const [copiedKey, setCopiedKey] = useState(false)
const supabaseUrl = `https://${project.project_ref}.supabase.co`
const copyToClipboard = async (text: string, type: 'url' | 'key') => {
await navigator.clipboard.writeText(text)
if (type === 'url') {
setCopiedUrl(true)
setTimeout(() => setCopiedUrl(false), 2000)
} else {
setCopiedKey(true)
setTimeout(() => setCopiedKey(false), 2000)
}
}
return (
<div className="bg-[#1C1C1C] border border-[#2C2C2C] rounded-lg overflow-hidden">
{/* Header - Always Visible */}
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full px-6 py-4 flex items-center justify-between hover:bg-[#2C2C2C]/30 transition-colors"
>
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-[#D0D0D0]">Project Information</span>
<span className="text-xs text-[#808080]">
{project.region} • Created {new Date(project.created_at).toLocaleDateString()}
</span>
</div>
{isOpen ? (
<ChevronUp className="w-5 h-5 text-[#808080]" />
) : (
<ChevronDown className="w-5 h-5 text-[#808080]" />
)}
</button>
{/* Collapsible Content */}
{isOpen && (
<div className="border-t border-[#2C2C2C] px-6 py-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Basic Information */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-[#808080] uppercase tracking-wide mb-3">
Basic Information
</h3>
<div>
<dt className="text-xs text-[#808080] mb-1">Organization ID</dt>
<dd className="text-sm text-[#D0D0D0] font-mono">{project.organization_id}</dd>
</div>
<div>
<dt className="text-xs text-[#808080] mb-1">Region</dt>
<dd className="text-sm text-[#D0D0D0]">{project.region}</dd>
</div>
<div>
<dt className="text-xs text-[#808080] mb-1">Created By</dt>
<dd className="text-sm text-[#D0D0D0]">{project.creator_email}</dd>
</div>
{project.purpose && (
<div>
<dt className="text-xs text-[#808080] mb-1">Purpose</dt>
<dd className="text-sm text-[#D0D0D0]">{project.purpose}</dd>
</div>
)}
</div>
{/* Connection Information */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-[#808080] uppercase tracking-wide mb-3">
Connection Info
</h3>
<div>
<dt className="text-xs text-[#808080] mb-2">Supabase URL</dt>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-[#0E0E0E] text-[#A0A0A0] px-3 py-2 rounded border border-[#2C2C2C] font-mono">
{supabaseUrl}
</code>
<button
onClick={() => copyToClipboard(supabaseUrl, 'url')}
className="p-2 hover:bg-[#2C2C2C] rounded transition-colors"
title="Copy URL"
>
{copiedUrl ? (
<Check className="w-4 h-4 text-[#3ECF8E]" />
) : (
<Copy className="w-4 h-4 text-[#808080]" />
)}
</button>
</div>
</div>
<div>
<dt className="text-xs text-[#808080] mb-2">Anon Key</dt>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-[#0E0E0E] text-[#A0A0A0] px-3 py-2 rounded border border-[#2C2C2C] font-mono truncate">
{project.anon_key}
</code>
<button
onClick={() => copyToClipboard(project.anon_key, 'key')}
className="p-2 hover:bg-[#2C2C2C] rounded transition-colors"
title="Copy key"
>
{copiedKey ? (
<Check className="w-4 h-4 text-[#3ECF8E]" />
) : (
<Copy className="w-4 h-4 text-[#808080]" />
)}
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
)
}
@@ -10,6 +10,7 @@
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Trash2, Loader2 } from 'lucide-react'
import {
pauseProject,
resumeProject,
@@ -82,16 +83,16 @@ export default function ProjectTable({ projects, isAdmin }: ProjectTableProps) {
const getStatusBadge = (status: 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 (
<span
className={`px-2 py-1 rounded-full text-xs font-medium border ${
className={`px-2.5 py-1 rounded-full text-xs font-medium border ${
styles[status] || styles.active
}`}
>
@@ -102,11 +103,11 @@ export default function ProjectTable({ projects, isAdmin }: ProjectTableProps) {
if (projects.length === 0) {
return (
<div className="text-center py-12 bg-gray-50 rounded-lg border border-gray-200">
<p className="text-gray-500 mb-4">No projects found</p>
<div className="text-center py-16 bg-[#1C1C1C] rounded-lg border border-[#2C2C2C]">
<p className="text-[#A0A0A0] mb-6 text-base">No projects found</p>
<Link
href="/projects/new"
className="inline-block px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
className="inline-block px-5 py-2.5 bg-[#3ECF8E] text-[#0E0E0E] rounded-md hover:bg-[#3ECF8E]/90 font-medium transition-colors"
>
Create Your First Project
</Link>
@@ -118,110 +119,95 @@ export default function ProjectTable({ projects, isAdmin }: ProjectTableProps) {
<div className="space-y-4">
{/* Error Display */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-800 rounded-lg p-4">
<div className="bg-red-500/10 border border-red-500/20 text-red-500 rounded-lg p-4">
<p className="font-medium">Error</p>
<p className="text-sm">{error}</p>
</div>
)}
{/* Table */}
<div className="overflow-x-auto border border-gray-200 rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<div className="overflow-x-auto border border-[#2C2C2C] rounded-lg bg-[#1C1C1C]">
<table className="min-w-full divide-y divide-[#2C2C2C]">
<thead>
<tr className="border-b border-[#2C2C2C]">
<th className="px-6 py-3.5 text-left text-xs font-medium text-[#808080] uppercase tracking-wider">
Project Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3.5 text-left text-xs font-medium text-[#808080] uppercase tracking-wider">
Reference
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3.5 text-left text-xs font-medium text-[#808080] uppercase tracking-wider">
Region
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3.5 text-left text-xs font-medium text-[#808080] uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3.5 text-left text-xs font-medium text-[#808080] uppercase tracking-wider">
Creator
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3.5 text-left text-xs font-medium text-[#808080] uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3.5 text-right text-xs font-medium text-[#808080] uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="divide-y divide-[#2C2C2C]">
{projects.map((project) => (
<tr key={project.id} className="hover:bg-gray-50">
<tr key={project.id} className="hover:bg-[#2C2C2C]/30 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<Link
href={`/projects/${project.id}`}
className="text-blue-600 hover:text-blue-800 font-medium"
className="text-[#3ECF8E] hover:text-[#3ECF8E]/80 font-medium transition-colors"
>
{project.project_name}
</Link>
{project.purpose && (
<p className="text-xs text-gray-500 mt-1">{project.purpose}</p>
<p className="text-xs text-[#808080] mt-1">{project.purpose}</p>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<code className="text-xs bg-gray-100 px-2 py-1 rounded">
<code className="text-xs bg-[#2C2C2C] text-[#A0A0A0] px-2.5 py-1 rounded font-mono">
{project.project_ref}
</code>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm text-[#D0D0D0]">
{project.region}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(project.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700">
<td className="px-6 py-4 whitespace-nowrap text-sm text-[#D0D0D0]">
{project.creator_email}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="px-6 py-4 whitespace-nowrap text-sm text-[#A0A0A0]">
{new Date(project.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<Link
href={`/projects/${project.id}`}
className="text-blue-600 hover:text-blue-800"
>
View
</Link>
{isAdmin && (
<>
{project.status === 'active' && (
<button
onClick={() => handlePause(project.id)}
disabled={loadingAction === project.id}
className="text-yellow-600 hover:text-yellow-800 disabled:text-gray-400"
>
{loadingAction === project.id ? 'Loading...' : 'Pause'}
</button>
)}
{project.status === 'paused' && (
<button
onClick={() => handleResume(project.id)}
disabled={loadingAction === project.id}
className="text-green-600 hover:text-green-800 disabled:text-gray-400"
>
{loadingAction === project.id ? 'Loading...' : 'Resume'}
</button>
)}
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-3">
<Link
href={`/projects/${project.id}`}
className="text-[#3ECF8E] hover:text-[#3ECF8E]/80 transition-colors"
>
View
</Link>
{isAdmin && project.status !== 'deleted' && (
<button
onClick={() => handleDelete(project.id)}
disabled={loadingAction === project.id}
className="text-red-600 hover:text-red-800 disabled:text-gray-400"
className="text-red-500 hover:text-red-400 disabled:text-[#505050] transition-colors p-1 rounded hover:bg-red-500/10"
title="Delete project"
>
{loadingAction === project.id ? 'Loading...' : 'Delete'}
{loadingAction === project.id ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</button>
</>
)}
)}
</div>
</td>
</tr>
))}
@@ -229,12 +215,6 @@ export default function ProjectTable({ projects, isAdmin }: ProjectTableProps) {
</table>
</div>
{/* Legend */}
<div className="text-xs text-gray-500 mt-4">
<p>
<strong>Note:</strong> {isAdmin ? 'As an admin, you can pause, resume, and delete projects.' : 'Contact an admin to pause, resume, or delete projects.'}
</p>
</div>
</div>
)
}
@@ -0,0 +1,417 @@
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import type { z, ZodTypeAny } from 'zod'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useEffect, useRef } from 'react'
interface DynamicFormProps<T extends z.ZodRawShape = z.ZodRawShape> {
schema: z.ZodObject<T>
onSubmit: (data: z.infer<z.ZodObject<T>>) => void
isLoading?: boolean
initialValues?: Partial<z.infer<z.ZodObject<T>>>
labels?: Record<string, string | { label: string; options?: Record<string, string> }>
columnInfo?: Record<string, { data_type: string; is_nullable: boolean }>
}
const getZodDef = (schema: ZodTypeAny): Record<string, any> =>
(schema as any)._def || (schema as any).def
const unwrapZodType = (fieldSchema: ZodTypeAny): ZodTypeAny => {
if (
!fieldSchema ||
typeof getZodDef(fieldSchema) !== 'object' ||
getZodDef(fieldSchema) === null
) {
throw new Error(
`unwrapZodType received an invalid Zod schema object. Check the console for the problematic schema/key.`
)
}
let currentSchema = fieldSchema
// Support both old (typeName) and new (type) Zod formats
const getTypeName = (def: Record<string, any>) => def.typeName || def.type
while (
getTypeName(getZodDef(currentSchema)) === 'ZodOptional' ||
getTypeName(getZodDef(currentSchema)) === 'optional' ||
getTypeName(getZodDef(currentSchema)) === 'ZodDefault' ||
getTypeName(getZodDef(currentSchema)) === 'default' ||
getTypeName(getZodDef(currentSchema)) === 'ZodNullable' ||
getTypeName(getZodDef(currentSchema)) === 'nullable' ||
getTypeName(getZodDef(currentSchema)) === 'ZodEffects' ||
getTypeName(getZodDef(currentSchema)) === 'effects'
) {
const typeName = getTypeName(getZodDef(currentSchema))
if (typeName === 'ZodEffects' || typeName === 'effects') {
// For ZodEffects, get the schema inside the effect
currentSchema = getZodDef(currentSchema).schema
} else {
currentSchema = getZodDef(currentSchema).innerType
}
}
return currentSchema
}
export function DynamicForm<T extends z.ZodRawShape = z.ZodRawShape>({
schema,
onSubmit,
isLoading = false,
initialValues,
labels,
columnInfo,
}: DynamicFormProps<T>) {
const isInitializingRef = useRef(true)
const defaultValues = Object.keys(schema.shape).reduce(
(acc, key) => {
const originalFieldSchema = schema.shape[key]
if (typeof originalFieldSchema === 'undefined') {
throw new Error(
`Schema error: schema.shape['${key}'] is undefined. Check schema definition.`
)
}
// Support both old (typeName) and new (type) Zod formats
const getTypeName = (def: Record<string, any>) => def.typeName || def.type
if (
getTypeName(getZodDef(originalFieldSchema)) === 'ZodDefault' ||
getTypeName(getZodDef(originalFieldSchema)) === 'default'
) {
acc[key] = getZodDef(originalFieldSchema).defaultValue()
return acc
}
const baseType = unwrapZodType(originalFieldSchema)
switch (getTypeName(getZodDef(baseType))) {
case 'ZodString':
case 'string':
acc[key] = ''
break
case 'ZodBoolean':
case 'boolean':
acc[key] = false // Default optional booleans to false
break
case 'ZodEnum':
case 'enum':
// For enums, use the first enum value as default
const enumValues = getZodDef(baseType).values || []
acc[key] = enumValues.length > 0 ? enumValues[0] : ''
break
case 'ZodNumber':
case 'number':
acc[key] = 0 // Default optional numbers to 0
break
case 'ZodArray':
case 'array':
acc[key] = [] // Default arrays to empty array
break
default:
acc[key] = undefined // For other types, or if truly no default makes sense
break
}
return acc
},
{} as Record<string, any>
)
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
defaultValues: defaultValues as any,
})
useEffect(() => {
if (initialValues) {
isInitializingRef.current = true
const schemaKeys = Object.keys(schema.shape)
const processedInitialValues = schemaKeys.reduce(
(acc, key) => {
const fieldDefFromSchema = schema.shape[key]
if (typeof fieldDefFromSchema === 'undefined') {
throw new Error(`Schema error in useEffect: schema.shape['${key}'] is undefined.`)
}
const value = initialValues.hasOwnProperty(key) ? initialValues[key] : undefined
const baseFieldType = unwrapZodType(fieldDefFromSchema)
// Support both old (typeName) and new (type) Zod formats
const getTypeName = (def: Record<string, any>) => def.typeName || def.type
const fieldTypeName = getTypeName(getZodDef(baseFieldType))
if (fieldTypeName === 'ZodBoolean' || fieldTypeName === 'boolean') {
acc[key] = !!value
} else if (fieldTypeName === 'ZodString' || fieldTypeName === 'string') {
acc[key] = value === null || value === undefined ? '' : String(value)
} else if (fieldTypeName === 'ZodEnum' || fieldTypeName === 'enum') {
const enumValues = getZodDef(baseFieldType).values || []
acc[key] =
value === null || value === undefined || !enumValues.includes(value)
? enumValues[0] || ''
: String(value)
} else if (fieldTypeName === 'ZodNumber' || fieldTypeName === 'number') {
const num = Number(value)
acc[key] = isNaN(num) ? 0 : num
} else if (fieldTypeName === 'ZodArray' || fieldTypeName === 'array') {
// For arrays, the value should already be an array from the database
// Handle null values properly
if (Array.isArray(value)) {
acc[key] = value
} else if (value === null || value === undefined) {
acc[key] = []
} else if (typeof value === 'string' && value.startsWith('{') && value.endsWith('}')) {
// Parse PostgreSQL array format like {ONE,TWO} into JavaScript array
try {
const innerContent = value.slice(1, -1) // Remove { and }
if (innerContent.trim() === '') {
acc[key] = []
} else {
// Split by comma and trim whitespace
acc[key] = innerContent.split(',').map((item: string) => item.trim())
}
} catch {
// If parsing fails, default to empty array
acc[key] = []
}
} else {
acc[key] = []
}
} else {
acc[key] = value
}
return acc
},
{} as Record<string, any>
)
form.reset(processedInitialValues as any)
setTimeout(() => {
isInitializingRef.current = false
}, 0)
} else {
isInitializingRef.current = false
}
}, [initialValues, form, schema])
const renderField = (fieldName: string, fieldSchema: ZodTypeAny) => {
const baseType = unwrapZodType(fieldSchema)
// Support both old (typeName) and new (type) Zod formats
const getTypeName = (def: Record<string, any>) => def.typeName || def.type
const typeName = getTypeName(getZodDef(baseType))
const description = fieldSchema.description
return (
<FormField
key={fieldName}
control={form.control}
name={fieldName as any}
render={({ field }) => {
const labelConfig = labels?.[fieldName]
const label =
typeof labelConfig === 'string' ? labelConfig : labelConfig?.label || fieldName
const typeDisplay = columnInfo?.[fieldName]?.data_type || ''
switch (typeName) {
case 'ZodString':
case 'string':
return (
<FormItem className="py-6 border-b">
<div className="w-full flex flex-col lg:flex-row lg:items-center justify-between w-full gap-4 lg:gap-8">
<div className="flex-1 pr-4">
<FormLabel>{label}</FormLabel>
<div className="text-sm text-muted-foreground mt-1">{typeDisplay}</div>
{description && <FormDescription>{description}</FormDescription>}
<FormMessage />
</div>
<div className="flex-1 lg:max-w-1/2">
<FormControl>
<Input
placeholder={`Enter your ${fieldName}`}
{...field}
value={String(field.value || '')}
/>
</FormControl>
</div>
</div>
</FormItem>
)
case 'ZodNumber':
case 'number':
return (
<FormItem className="py-6 border-b">
<div className="w-full flex flex-col lg:flex-row lg:items-center justify-between w-full gap-4 lg:gap-8">
<div className="flex-1 pr-4">
<FormLabel>{label}</FormLabel>
<div className="text-sm text-muted-foreground">{typeDisplay}</div>
{description && <FormDescription>{description}</FormDescription>}
<FormMessage />
</div>
<div className="flex-1 lg:max-w-1/2">
<FormControl>
<Input
type="number"
placeholder={`Enter value for ${fieldName}`}
{...field}
value={String(field.value ?? '')}
onChange={(e) => {
const value = e.target.value
const num = parseInt(value, 10)
field.onChange(isNaN(num) ? undefined : num)
}}
/>
</FormControl>
</div>
</div>
</FormItem>
)
case 'ZodBoolean':
case 'boolean':
return (
<FormItem className="py-6 border-b flex flex-row items-center justify-between gap-8">
<div>
<FormLabel>{label}</FormLabel>
<div className="text-sm text-muted-foreground">{typeDisplay}</div>
{description && <FormDescription>{description}</FormDescription>}
<FormMessage />
</div>
<FormControl>
<Switch checked={!!field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)
case 'ZodEnum':
case 'enum':
const options = getZodDef(baseType).values
const optionLabels = typeof labelConfig === 'object' ? labelConfig.options : undefined
return (
<FormItem className="py-6 border-b">
<div className="w-full flex flex-col lg:flex-row lg:items-center justify-between w-full gap-4 lg:gap-8">
<div className="flex-1 pr-4">
<FormLabel>{label}</FormLabel>
<div className="text-sm text-muted-foreground">{typeDisplay}</div>
{description && <FormDescription>{description}</FormDescription>}
<FormMessage />
</div>
<div className="flex-1 lg:max-w-1/2">
<Select
onValueChange={(value) => {
if (!isInitializingRef.current) {
field.onChange(value)
}
}}
value={String(field.value || '')}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={`Select a ${fieldName}`} />
</SelectTrigger>
</FormControl>
<SelectContent>
{options.map((option: string) => (
<SelectItem key={option} value={option}>
{optionLabels?.[option] || option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</FormItem>
)
case 'ZodArray':
case 'array':
return (
<FormItem className="py-6 border-b">
<div className="flex flex-row items-center justify-between w-full gap-8">
<div className="flex-1 pr-4">
<FormLabel>{label}</FormLabel>
<div className="text-sm text-muted-foreground">
Enter as JSON array: [&quot;item1&quot;, &quot;item2&quot;]
{columnInfo?.[fieldName]?.is_nullable && ' (leave empty for null)'}
</div>
{description && <FormDescription>{description}</FormDescription>}
<FormMessage />
</div>
<div className="flex-1">
<FormControl>
<Input
placeholder={
columnInfo?.[fieldName]?.is_nullable
? `[&quot;item1&quot;, &quot;item2&quot;] or leave empty for null`
: `[&quot;item1&quot;, &quot;item2&quot;]`
}
{...field}
value={
field.value === null || field.value === undefined
? ''
: Array.isArray(field.value)
? JSON.stringify(field.value)
: String(field.value || '')
}
onChange={(e) => {
const value = e.target.value
if (value.trim() === '') {
// Empty string means null (for nullable) or empty array (for non-nullable)
field.onChange(null)
} else {
try {
const parsed = JSON.parse(value)
if (Array.isArray(parsed)) {
field.onChange(parsed)
} else {
// If it's not an array, treat as invalid input
field.onChange(value)
}
} catch {
// Invalid JSON, keep the string value to show error
field.onChange(value)
}
}
}}
/>
</FormControl>
</div>
</div>
</FormItem>
)
default:
return <></>
}
}}
/>
)
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
{Object.keys(schema.shape).map((fieldName) =>
renderField(fieldName, schema.shape[fieldName])
)}
<div className="pt-6">
<Button type="submit" disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save'}
</Button>
</div>
</form>
</Form>
)
}
@@ -0,0 +1,49 @@
export function LogoSupabase({ className, size = 24 }: { className?: string; size?: number }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
viewBox="0 0 109 113"
className={className}
style={{ width: size, height: size }}
>
<path
fill="url(#supabase_svg__a)"
d="M63.708 110.284c-2.86 3.601-8.658 1.628-8.727-2.97l-1.007-67.251h45.22c8.19 0 12.758 9.46 7.665 15.874z"
></path>
<path
fill="url(#supabase_svg__b)"
fillOpacity="0.2"
d="M63.708 110.284c-2.86 3.601-8.658 1.628-8.727-2.97l-1.007-67.251h45.22c8.19 0 12.758 9.46 7.665 15.874z"
></path>
<path
fill="#3ECF8E"
d="M45.317 2.071c2.86-3.601 8.657-1.628 8.726 2.97l.442 67.251H9.83c-8.19 0-12.759-9.46-7.665-15.875z"
></path>
<defs>
<linearGradient
id="supabase_svg__a"
x1="53.974"
x2="94.163"
y1="54.974"
y2="71.829"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#249361"></stop>
<stop offset="1" stopColor="#3ECF8E"></stop>
</linearGradient>
<linearGradient
id="supabase_svg__b"
x1="36.156"
x2="54.484"
y1="30.578"
y2="65.081"
gradientUnits="userSpaceOnUse"
>
<stop></stop>
<stop offset="1" stopOpacity="0"></stop>
</linearGradient>
</defs>
</svg>
)
}
@@ -0,0 +1,60 @@
'use client'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { cn } from '@/lib/utils'
interface ResultsTableProps {
data: any[]
onRowClick?: (row: any) => void
}
export function ResultsTable({ data, onRowClick }: ResultsTableProps) {
if (!data || data.length === 0) {
return <p className="p-4 text-center">The query returned no results.</p>
}
const headers = Object.keys(data[0])
return (
<div className="overflow-auto">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
{headers.map((header) => (
<TableHead className="first:pl-6 lg:first:pl-8 last:pr-6 lg:last:pr-8" key={header}>
{header}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.map((row, rowIndex) => (
<TableRow
key={rowIndex}
onClick={() => onRowClick?.(row)}
className={cn(onRowClick && 'cursor-pointer hover:bg-muted/50 group')}
>
{headers.map((header) => (
<TableCell
className="first:pl-6 lg:first:pl-8 last:pr-6 lg:last:pr-8 text-xs text-muted-foreground group-hover:text-foreground min-w-[8rem]"
key={`${rowIndex}-${header}`}
>
<div className="text-xs font-mono w-fit max-w-96 truncate">
{JSON.stringify(row[header]).replace(/^"|"$/g, '')}
</div>
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
@@ -0,0 +1,420 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Editor } from '@monaco-editor/react'
import { Button } from '@/components/ui/button'
import { ResultsTable } from '@/components/results-table'
import { Label } from '@/components/ui/label'
import { useRunQuery } from '@/hooks/use-run-query'
import {
ArrowUp,
Loader2,
BarChart as BarChartIcon,
Wand,
FileText,
AlertTriangle,
} from 'lucide-react'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { Switch } from '@/components/ui/switch'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
interface SqlEditorProps {
projectRef: string
initialSql?: string
queryKey?: any
label?: string
onResults?: (data: any[] | undefined) => void
onRowClick?: (row: any, queryKey?: any) => void
hideSql?: boolean
readOnly?: boolean
runAutomatically?: boolean
refetch?: number
initialNaturalLanguageMode?: boolean
hideChartOption?: boolean
}
export function SqlEditor({
projectRef,
initialSql,
queryKey,
onResults,
onRowClick,
label = 'Query your data',
hideSql = false,
readOnly = false,
runAutomatically = false,
refetch,
initialNaturalLanguageMode = false,
hideChartOption = false,
}: SqlEditorProps) {
const [sql, setSql] = useState(initialSql || '')
const [isSqlVisible, setIsSqlVisible] = useState(!hideSql)
const [isNaturalLanguageMode, setIsNaturalLanguageMode] = useState(
process.env.NEXT_PUBLIC_ENABLE_AI_QUERIES === 'true' && initialNaturalLanguageMode
)
const [naturalLanguageQuery, setNaturalLanguageQuery] = useState('')
const { mutate: runQuery, data, isPending, error } = useRunQuery()
const [isGeneratingSql, setIsGeneratingSql] = useState(false)
const [aiError, setAiError] = useState<string | null>(null)
const [isChartVisible, setIsChartVisible] = useState(false)
const [xAxisColumn, setXAxisColumn] = useState<string | null>(null)
const [yAxisColumn, setYAxisColumn] = useState<string | null>(null)
const columns = useMemo(() => {
if (!data || data.length === 0) return []
return Object.keys(data[0])
}, [data])
useEffect(() => {
if (initialSql) {
setSql(initialSql)
}
}, [initialSql])
const handleRunNaturalLanguageQuery = async () => {
if (!naturalLanguageQuery) return
setIsGeneratingSql(true)
setAiError(null)
try {
const response = await fetch('/api/ai/sql', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt: naturalLanguageQuery,
projectRef,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.message || 'Failed to generate SQL')
}
const { sql: generatedSql } = await response.json()
setSql(generatedSql)
runQuery({ projectRef, query: generatedSql, readOnly: true })
} catch (error: any) {
console.error(error)
setAiError(error.message)
} finally {
setIsGeneratingSql(false)
}
}
const handleRunQuery = useCallback(() => {
if (sql) {
runQuery({ projectRef, query: sql, readOnly: true })
}
}, [sql, projectRef, runQuery])
useEffect(() => {
setIsSqlVisible(!hideSql)
}, [hideSql])
useEffect(() => {
if (runAutomatically && initialSql) {
runQuery({ projectRef, query: initialSql, readOnly: true })
}
}, [runAutomatically, initialSql, projectRef, runQuery])
useEffect(() => {
if (refetch && refetch > 0) {
handleRunQuery()
}
}, [refetch, handleRunQuery])
useEffect(() => {
if (onResults) {
onResults(data)
}
}, [data, onResults])
useEffect(() => {
const noResults = !data || (Array.isArray(data) && data.length === 0)
if (noResults && !isSqlVisible && !isNaturalLanguageMode && !readOnly && !isPending) {
setIsSqlVisible(true)
}
}, [data, isSqlVisible, isNaturalLanguageMode])
const serverErrorMessage = (error as any)?.response?.data?.message || ''
const isReadOnlyError =
serverErrorMessage.includes('permission denied') || serverErrorMessage.includes('42501')
const customReadOnlyError = "You can't directly alter your database schema, use chat instead"
// Build the toggle-group selection based on current UI state
const toggleValues = useMemo(() => {
const values: string[] = []
if (isNaturalLanguageMode) values.push('chat')
if (isSqlVisible) values.push('sql')
if (!hideChartOption && isChartVisible) values.push('chart')
return values
}, [isNaturalLanguageMode, isSqlVisible, isChartVisible, hideChartOption])
const handleToggleGroupChange = (values: string[]) => {
setIsNaturalLanguageMode(values.includes('chat'))
setIsSqlVisible(values.includes('sql'))
if (!hideChartOption) {
setIsChartVisible(values.includes('chart'))
}
}
return (
<div>
<div className="px-6 pt-4 lg:px-8 lg:pt-8">
<div className="flex items-center gap-4 mb-4 ">
<h2 className="font-semibold flex-1">{label}</h2>
<ToggleGroup
type="multiple"
size="sm"
value={toggleValues}
onValueChange={handleToggleGroupChange}
className="gap-1"
>
{process.env.NEXT_PUBLIC_ENABLE_AI_QUERIES === 'true' && (
<ToggleGroupItem value="chat" aria-label="Chat">
<Wand className="h-4 w-4" />
</ToggleGroupItem>
)}
<ToggleGroupItem value="sql" aria-label="SQL">
<FileText className="h-4 w-4" />
</ToggleGroupItem>
{!hideChartOption && (
<Popover>
<PopoverTrigger asChild>
<ToggleGroupItem value="chart" aria-label="Chart">
<BarChartIcon className="h-4 w-4" />
</ToggleGroupItem>
</PopoverTrigger>
<PopoverContent className="w-80">
<div className="grid gap-4">
<div className="grid gap-2">
<div className="flex items-center space-x-2">
<div className="flex-1">
<Label className="flex-1 mb-2 block">Show Chart</Label>
<p className="text-xs text-muted-foreground">
Visualize your data with a chart.
</p>
</div>
<Switch
id="show-chart"
size="sm"
className="mb-0"
checked={isChartVisible}
onCheckedChange={setIsChartVisible}
/>
</div>
{isChartVisible && (
<div className="mt-2">
<div className="grid grid-cols-3 items-center gap-4 mb-2">
<Label htmlFor="x-axis">X-Axis</Label>
<Select
onValueChange={setXAxisColumn}
defaultValue={xAxisColumn || undefined}
>
<SelectTrigger className="col-span-2 h-8">
<SelectValue placeholder="Select column" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col} value={col}>
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-3 items-center gap-4 mb-2">
<Label htmlFor="y-axis">Y-Axis</Label>
<Select
onValueChange={setYAxisColumn}
defaultValue={yAxisColumn || undefined}
>
<SelectTrigger className="col-span-2 h-8">
<SelectValue placeholder="Select column" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col} value={col}>
{col}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</PopoverContent>
</Popover>
)}
</ToggleGroup>
</div>
<div>
{isNaturalLanguageMode && (
<div className="relative mb-4">
<Wand
strokeWidth={1.5}
size={16}
className="absolute left-4 top-1/2 -translate-y-1/2"
/>
<Input
placeholder="e.g. Show me all users who signed up in the last 7 days"
value={naturalLanguageQuery}
onChange={(e) => setNaturalLanguageQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleRunNaturalLanguageQuery()
}
}}
className="w-full px-10"
/>
<Button
onClick={handleRunNaturalLanguageQuery}
disabled={isGeneratingSql || isPending}
className="h-7 w-7 rounded-full p-0 shrink-0 absolute right-1 top-1/2 -translate-y-1/2"
>
{isGeneratingSql ? (
<Loader2 size={16} className="animate-spin" />
) : isPending ? (
<Loader2 size={16} className="animate-spin" />
) : (
<ArrowUp size={16} />
)}
</Button>
</div>
)}
{isSqlVisible && (
<div className="border-t border-b bg-muted overflow-hidden -mx-6 lg:-mx-8 mt-4 relative">
<Editor
height="200px"
language="sql"
value={sql}
onChange={(value) => setSql(value || '')}
theme="vs-dark"
className="bg-transparent"
options={{
minimap: { enabled: false },
fontSize: 14,
readOnly,
padding: {
top: 24,
bottom: 24,
},
}}
/>
<Button
size="sm"
onClick={handleRunQuery}
disabled={isPending}
className="absolute bottom-4 right-4"
>
{isPending ? 'Running...' : 'Run Query'}
</Button>
</div>
)}
</div>
</div>
{isPending && (
<div className="space-y-2 p-4 px-6 lg:px-8">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
</div>
)}
{aiError && (
<div className="px-6 lg:px-8 py-4">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error generating SQL</AlertTitle>
<AlertDescription>{aiError}</AlertDescription>
</Alert>
</div>
)}
{error && (
<div className="px-6 lg:px-8 py-4">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Query Error</AlertTitle>
<AlertDescription>
{isReadOnlyError
? customReadOnlyError
: serverErrorMessage || (error as Error)?.message || 'An unexpected error occurred'}
</AlertDescription>
</Alert>
</div>
)}
{!hideChartOption && data && isChartVisible && xAxisColumn && yAxisColumn && (
<div className="px-8 mt-8 mb-4">
<QueryResultChart data={data} xAxis={xAxisColumn} yAxis={yAxisColumn} />
</div>
)}
{data && (
<div>
<ResultsTable data={data} onRowClick={(row) => onRowClick?.(row, queryKey)} />
</div>
)}
</div>
)
}
function QueryResultChart({ data, xAxis, yAxis }: { data: any[]; xAxis: string; yAxis: string }) {
const chartConfig = {
[yAxis]: {
label: yAxis,
color: 'var(--chart-1)',
},
} satisfies ChartConfig
return (
<ChartContainer config={chartConfig} className="aspect-auto h-[250px] w-full">
<BarChart
accessibilityLayer
data={data}
margin={{
left: -24,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis dataKey={xAxis} tickLine={false} axisLine={false} tickMargin={8} minTickGap={32} />
<YAxis
dataKey={yAxis}
tickLine={false}
axisLine={false}
tickMargin={8}
tickCount={5}
allowDecimals={false}
/>
<ChartTooltip content={<ChartTooltipContent className="w-[150px]" indicator="dot" />} />
<Bar dataKey={yAxis} fill={`var(--color-${yAxis})`} />
</BarChart>
</ChartContainer>
)
}
@@ -0,0 +1,244 @@
'use client'
import { DynamicForm } from '@/components/dynamic-form'
import {
useGetAuthConfig,
useUpdateAuthConfig,
} from '@/hooks/use-auth'
import {
authEmailProviderSchema,
authFieldLabels,
authGeneralSettingsSchema,
type AuthGeneralSettingsSchema,
authGoogleProviderSchema,
authPhoneProviderSchema,
} from '../../lib/schemas/auth'
import { AlertTriangle, ChevronRight, Mail, Phone, User } from 'lucide-react'
import { useCallback, useMemo } from 'react'
import { z } from 'zod'
import { useSheetNavigation } from '@/contexts/SheetNavigationContext'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/ui/button'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
function ProviderSettingsView({
projectRef,
schema,
title,
initialValues: allInitialValues,
onSuccess,
}: {
projectRef: string
schema: z.ZodObject<any> | z.ZodEffects<z.ZodObject<any>>
title: string
initialValues: any
onSuccess: () => void
}) {
const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useUpdateAuthConfig()
const actualSchema = 'shape' in schema ? schema : (schema._def.schema as z.ZodObject<any>)
const handleUpdateAuthConfig = (formData: z.infer<typeof actualSchema>) => {
const payload = Object.fromEntries(
Object.entries(formData).filter(([_, value]) => value !== undefined)
)
if (Object.keys(payload).length === 0) {
alert('No changes to submit. Please modify a field to update.')
return
}
updateAuthConfig(
{
projectRef,
payload,
},
{
onSuccess,
}
)
}
const formInitialValues = useMemo(() => {
if (!allInitialValues) {
return undefined
}
const schemaKeys = Object.keys(actualSchema.shape)
const result = schemaKeys.reduce(
(acc, key) => {
if (Object.prototype.hasOwnProperty.call(allInitialValues, key)) {
acc[key] = allInitialValues[key]
}
return acc
},
{} as Record<string, any>
)
return result
}, [allInitialValues, actualSchema])
return (
<div className="w-full max-w-3xl mx-auto p-6 pt-4 lg:p-12 lg:pt-12">
<h2 className="lg:text-xl font-semibold mb-2 lg:mb-4">{title}</h2>
<DynamicForm
schema={actualSchema}
onSubmit={handleUpdateAuthConfig}
isLoading={isUpdatingConfig}
initialValues={formInitialValues}
labels={authFieldLabels}
/>
</div>
)
}
export function AuthManager({ projectRef }: { projectRef: string }) {
const {
data: authConfigData,
isLoading: isLoadingConfig,
error: errorLoadingConfig,
} = useGetAuthConfig(projectRef)
const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useUpdateAuthConfig()
const { push, pop } = useSheetNavigation()
const handleUpdateGeneralSettings = (formData: AuthGeneralSettingsSchema) => {
const payload = Object.fromEntries(
Object.entries(formData).filter(([_, value]) => value !== undefined)
)
if (Object.keys(payload).length === 0) {
alert('No changes to submit. Please modify a field to update.')
return
}
updateAuthConfig({
projectRef,
payload,
})
}
const providers: {
name: string
icon: React.ReactNode
description: string
schema: z.ZodObject<any> | z.ZodEffects<z.ZodObject<any>>
}[] = [
{
icon: <Mail className="h-4 w-4 text-muted-foreground" />,
name: 'Email',
description: 'Sign in with email and password',
schema: authEmailProviderSchema,
},
{
icon: <Phone className="h-4 w-4 text-muted-foreground" />,
name: 'Phone',
description: 'Sign in with phone number',
schema: authPhoneProviderSchema,
},
{
icon: <User className="h-4 w-4 text-muted-foreground" />,
name: 'Google',
description: 'Sign in with Google',
schema: authGoogleProviderSchema,
},
]
const handleProviderClick = useCallback(
(provider: { name: string; schema: z.ZodObject<any> | z.ZodEffects<z.ZodObject<any>> }) => {
push({
title: `${provider.name} Provider Settings`,
component: (
<ProviderSettingsView
projectRef={projectRef}
schema={provider.schema}
title={`${provider.name} Provider Settings`}
initialValues={authConfigData}
onSuccess={() => pop()}
/>
),
})
},
[projectRef, authConfigData, push, pop]
)
const formInitialValues = useMemo(() => {
if (!authConfigData) {
return undefined
}
const schemaKeys = Object.keys(authGeneralSettingsSchema.shape)
return schemaKeys.reduce(
(acc, key) => {
if (Object.prototype.hasOwnProperty.call(authConfigData, key)) {
acc[key] = authConfigData[key as keyof typeof authConfigData]
}
return acc
},
{} as Record<string, any>
)
}, [authConfigData])
if (errorLoadingConfig) {
return (
<div className="mx-6 lg:mx-8 mt-8">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error loading auth settings</AlertTitle>
<AlertDescription>
There was a problem loading your authentication configuration.
</AlertDescription>
</Alert>
</div>
)
}
if (isLoadingConfig)
return (
<div className="w-full max-w-3xl mx-auto p-12 space-y-2">
<Skeleton className="h-12 w-full mb-8" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
)
return (
<div className="w-full max-w-3xl mx-auto p-6 pt-4 lg:p-12 lg:pt-12">
<div className="mb-12">
<h2 className="text-base lg:text-xl font-semibold">General Settings</h2>
<p className="text-sm lg:text-base text-muted-foreground mt-1 mb-2">
Allow people to sign up to your app
</p>
<DynamicForm
schema={authGeneralSettingsSchema}
onSubmit={handleUpdateGeneralSettings}
isLoading={isUpdatingConfig}
initialValues={formInitialValues}
labels={authFieldLabels}
/>
</div>
<div>
<h2 className="font-semibold lg:text-lg">Sign in methods</h2>
<p className="text-muted-foreground mb-6 mt-1 text-sm lg:text-base">
Configure how people sign in and up to your app.
</p>
<div className="border rounded-md overflow-hidden bg-background">
{providers.map((provider) => (
<Button
variant="ghost"
size="lg"
onClick={() => handleProviderClick(provider)}
key={provider.name}
className="rounded-none justify-start flex-row text-left h-auto px-8 py-4 w-full gap-4 border-b last:border-b-0"
>
{provider.icon}
<div className="flex-1">
<h3 className="font-bold">{provider.name}</h3>
<p className="text-muted-foreground text-sm">{provider.description}</p>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</Button>
))}
</div>
</div>
</div>
)
}
@@ -0,0 +1,330 @@
'use client'
import { useState, useMemo, useCallback } from 'react'
import { z, type ZodTypeAny } from 'zod'
import { useListTables } from '@/hooks/use-tables'
import { useRunQuery } from '@/hooks/use-run-query'
import { SqlEditor } from '@/components/sql-editor'
import { DynamicForm } from '@/components/dynamic-form'
import { toast } from 'sonner'
import { useSheetNavigation } from '@/contexts/SheetNavigationContext'
import { Skeleton } from '@/components/ui/skeleton'
import { Button } from '@/components/ui/button'
import { AlertTriangle, Table, Wand } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
// Helper to generate a Zod schema from the table's column definitions
function generateZodSchema(table: any): z.ZodObject<any, any, any> {
if (!table || !table.columns) {
return z.object({})
}
const shape: Record<string, ZodTypeAny> = {}
for (const column of table.columns) {
// We can only edit updatable columns that are not generated
if (!column.is_updatable || column.is_generated) continue
let fieldSchema: ZodTypeAny
const dataType = column.data_type.toLowerCase()
if (dataType.includes('array')) {
fieldSchema = z.array(z.any())
} else if (dataType.includes('int') || dataType.includes('numeric')) {
fieldSchema = z.number()
} else if (dataType.includes('bool')) {
fieldSchema = z.boolean()
} else if (
dataType === 'user-defined' &&
column.enums &&
Array.isArray(column.enums) &&
column.enums.length > 0
) {
// Handle enum types
fieldSchema = z.enum(column.enums as [string, ...string[]])
} else {
// Default to string for text, varchar, timestamp, uuid, etc.
fieldSchema = z.string()
}
if (column.is_nullable) {
fieldSchema = fieldSchema.nullish()
}
shape[column.name] = fieldSchema
}
return z.object(shape)
}
const getPrimaryKeys = (table: any): string[] => {
if (!table || !table.primary_keys) {
return []
}
return table.primary_keys.map((pk: any) => pk.name)
}
function EditRowView({
projectRef,
table,
row,
onSuccess,
}: {
projectRef: string
table: any
row: any
onSuccess: () => void
}) {
const { mutate: runUpdateQuery, isPending: isUpdatePending } = useRunQuery()
const formSchema = useMemo(() => generateZodSchema(table), [table])
const columnInfo = useMemo(() => {
if (!table || !table.columns) return {}
const info: Record<string, any> = {}
for (const column of table.columns) {
// Only include updatable columns that are not generated
if (!column.is_updatable || column.is_generated) continue
const dataType = column.data_type.toLowerCase()
const displayType =
dataType === 'user-defined' &&
column.enums &&
Array.isArray(column.enums) &&
column.enums.length > 0
? 'enum'
: dataType
info[column.name] = {
data_type: displayType,
is_nullable: column.is_nullable,
}
}
return info
}, [table])
const handleFormSubmit = useCallback(
(formData: any) => {
const pks = getPrimaryKeys(table)
if (pks.length === 0) {
toast.error('Cannot update row. This table does not have a primary key.')
return
}
const setClauses = Object.entries(formData)
.map(([key, value]) => {
if (JSON.stringify(row[key]) === JSON.stringify(value)) return null
// Find the column type to determine formatting
const column = table.columns.find((col: any) => col.name === key)
const dataType = column?.data_type?.toLowerCase() || ''
const isNullable = column?.is_nullable || false
let formattedValue
// Handle empty/null values
if (
value === null ||
value === undefined ||
(typeof value === 'string' && value.trim() === '')
) {
if (isNullable) {
formattedValue = 'NULL'
} else if (dataType.includes('array')) {
// Non-nullable array, set to empty array
const jsonObj = JSON.stringify({ [key]: [] })
formattedValue = `(select ${key} from json_populate_record(null::public."${
table.name
}", '${jsonObj.replace(/'/g, "''")}'))`
} else {
// Non-nullable text field, set to empty string
formattedValue = "''"
}
} else if (Array.isArray(value) && value.length === 0) {
// Explicitly empty array (different from null)
const jsonObj = JSON.stringify({ [key]: [] })
formattedValue = `(select ${key} from json_populate_record(null::public."${
table.name
}", '${jsonObj.replace(/'/g, "''")}'))`
} else if (dataType.includes('array')) {
// Array type with actual values - use json_populate_record syntax
const jsonObj = JSON.stringify({ [key]: value })
formattedValue = `(select ${key} from json_populate_record(null::public."${
table.name
}", '${jsonObj.replace(/'/g, "''")}'))`
} else if (dataType === 'user-defined' && column?.enums) {
// Handle enum values - treat as strings with proper escaping
formattedValue = `'${String(value).replace(/'/g, "''")}'`
} else if (typeof value === 'string') {
formattedValue = `'${value.replace(/'/g, "''")}'`
} else {
formattedValue = value
}
return `"${key}" = ${formattedValue}`
})
.filter(Boolean)
.join(', ')
if (!setClauses) {
toast.error('No changes to save')
onSuccess()
return
}
const whereClauses = pks
.map((pk) => {
const value = row[pk]
const formattedValue =
typeof value === 'string' ? `'${value.replace(/'/g, "''")}'` : value
return `"${pk}" = ${formattedValue}`
})
.join(' AND ')
const updateSql = `UPDATE public."${table.name}" SET ${setClauses} WHERE ${whereClauses};`
runUpdateQuery({ projectRef, query: updateSql, readOnly: false }, { onSuccess })
},
[projectRef, table, row, runUpdateQuery, onSuccess]
)
return (
<div className="px-6 pt-4 pb-10 lg:px-12 lg:pt-10">
<h2 className="text-base lg:text-xl font-semibold mb-4">Editing row in {table.name}</h2>
<DynamicForm
schema={formSchema}
initialValues={row}
onSubmit={handleFormSubmit}
isLoading={isUpdatePending}
columnInfo={columnInfo}
/>
</div>
)
}
function TableRecordsView({ projectRef, table }: { projectRef: string; table: any }) {
const { push, pop } = useSheetNavigation()
const [refetchCounter, setRefetchCounter] = useState(0)
const handleRowClick = useCallback(
(row: any) => {
push({
title: `Editing row`,
component: (
<EditRowView
projectRef={projectRef}
table={table}
row={row}
onSuccess={() => {
pop()
setRefetchCounter((c) => c + 1)
}}
/>
),
})
},
[push, pop, projectRef, table]
)
return (
<SqlEditor
hideChartOption={true}
projectRef={projectRef}
label={`View records in ${table.name}`}
initialSql={`SELECT * FROM public."${table.name}" LIMIT 100;`}
queryKey={table.id}
onRowClick={handleRowClick}
hideSql={true}
runAutomatically={true}
refetch={refetchCounter}
readOnly
/>
)
}
export function DatabaseManager({ projectRef }: { projectRef: string }) {
const { push } = useSheetNavigation()
const { data: tables, isLoading, isError } = useListTables(projectRef, ['public'])
const handleTableClick = useCallback(
(table: any) => {
push({
title: table.name,
component: <TableRecordsView projectRef={projectRef} table={table} />,
})
},
[push, projectRef]
)
const handleNaturalLanguageQueryClick = useCallback(() => {
push({
title: 'Talk to your database',
component: (
<SqlEditor projectRef={projectRef} initialNaturalLanguageMode={true} hideSql={true} />
),
})
}, [push, projectRef])
return (
<div className="p-6 pt-4 lg:p-8 lg:pt-8">
<div className="flex items-center justify-between mb-6 gap-6">
<div className="flex-1">
<h1 className="text-base lg:text-xl font-semibold">Database</h1>
<p className="hidden lg:block text-sm lg:text-base text-muted-foreground mt-1">
View and manage the data stored in your app.
</p>
</div>
<Button
variant="outline"
key="talk-to-db"
className="flex-row justify-between"
onClick={handleNaturalLanguageQueryClick}
>
<Wand strokeWidth={1.5} size={16} />
Query your database
</Button>
</div>
{isError && (
<Alert variant="destructive" className="mt-8">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error loading tables</AlertTitle>
<AlertDescription>There was a problem loading your database tables.</AlertDescription>
</Alert>
)}
{isLoading && (
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
)}
{tables && tables.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{tables.map((table: any) => (
<Button
variant="outline"
key={table.id}
size="lg"
className="flex-row justify-between text-left"
onClick={() => handleTableClick(table)}
>
<Table className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-medium font-mono truncate flex-1">{table.name}</h2>
<div className="text-sm text-muted-foreground font-mono shrink-0">
{table.live_rows_estimate} rows
</div>
</Button>
))}
</div>
) : !isLoading && (!tables || tables.length === 0) ? (
<Alert className="mt-8">
<Table className="h-4 w-4" />
<AlertTitle>No database tables</AlertTitle>
<AlertDescription>Create tables to store and organize your data.</AlertDescription>
</Alert>
) : null}
</div>
)
}
@@ -0,0 +1,301 @@
'use client'
import { useState, ReactNode, useMemo } from 'react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/drawer'
import { AuthManager } from '@/components/supabase-manager/auth'
import { DatabaseManager } from '@/components/supabase-manager/database'
import { StorageManager } from '@/components/supabase-manager/storage'
import { LogsManager } from '@/components/supabase-manager/logs'
import { SuggestionsManager } from '@/components/supabase-manager/suggestions'
import { UsersManager } from '@/components/supabase-manager/users'
import { SecretsManager } from '@/components/supabase-manager/secrets'
import {
SheetNavigationProvider,
useSheetNavigation,
} from '@/contexts/SheetNavigationContext'
import {
ChevronLeft,
ChevronRight,
Database,
ExternalLink,
HardDrive,
KeyRound,
Lightbulb,
ScrollText,
Shield,
Users,
} from 'lucide-react'
import {
HoverCard,
HoverCardTrigger,
HoverCardContent,
} from '@/components/ui/hover-card'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { LogoSupabase } from '@/components/logo-supabase'
import Link from 'next/link'
const queryClient = new QueryClient()
function DialogView({ projectRef, isMobile }: { projectRef: string; isMobile?: boolean }) {
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 = useMemo(
() => [
{
title: 'Database',
icon: Database,
component: <DatabaseManager projectRef={projectRef} />,
},
{
title: 'Storage',
icon: HardDrive,
component: <StorageManager projectRef={projectRef} />,
},
{
title: 'Auth',
icon: Shield,
component: <AuthManager projectRef={projectRef} />,
},
{
title: 'Users',
icon: Users,
component: <UsersManager projectRef={projectRef} />,
},
{
title: 'Secrets',
icon: KeyRound,
component: <SecretsManager projectRef={projectRef} />,
},
{
title: 'Logs',
icon: ScrollText,
component: <LogsManager projectRef={projectRef} />,
},
{
title: 'Suggestions',
icon: Lightbulb,
component: <SuggestionsManager projectRef={projectRef} />,
},
],
[projectRef]
)
if (isMobile) {
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Content Area */}
<div className="flex flex-col overflow-hidden h-full">
<div className="grow overflow-y-auto">
{currentView ? (
currentView.component
) : (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">
Select a manager from the bottom navigation to get started.
</p>
</div>
)}
</div>
</div>
{/* Bottom Navigation Bar */}
<div className="border-t bg-background">
<div className="overflow-x-auto">
<div className="flex gap-1 p-2 min-w-max">
{navigationItems.map((item) => {
const Icon = item.icon
return (
<Button
key={item.title}
variant={activeManager === item.title ? 'secondary' : 'ghost'}
className="flex-col h-16 w-20 min-w-16 text-xs gap-1 px-2"
onClick={() => handleTopLevelNavigation(item.title, item.component)}
>
<Icon className="h-5 w-5" />
<span className="text-[10px] leading-tight text-center">
{item.title === 'Auth' ? 'Auth' : item.title}
</span>
</Button>
)
})}
</div>
</div>
</div>
</div>
)
}
return (
<div className="grid grid-cols-[240px_1fr] h-full overflow-hidden">
{/* Sidebar */}
<div className="flex flex-col border-r px-3 py-6 pb-3">
<div className="px-4 mb-4">
<h2 className="text-muted-foreground font-semibold text-sm">Manage your back-end</h2>
</div>
<div className="grow space-y-0.5">
{navigationItems.map((item) => {
const Icon = item.icon
return (
<Button
key={item.title}
variant={activeManager === item.title ? 'secondary' : 'ghost'}
className="w-full justify-start text-sm"
onClick={() => handleTopLevelNavigation(item.title, item.component)}
>
<Icon className="mr-2 text-muted-foreground" />
{item.title === 'Auth' ? 'Authentication' : item.title}
</Button>
)
})}
</div>
<footer className="p-0 text-sm text-muted-foreground flex items-center gap-3 -m-3 border-t">
<HoverCard>
<HoverCardTrigger asChild>
<Link
href={`https://supabase.com/dashboard/project/${projectRef}`}
target="_blank"
className="flex items-center px-4 w-full rounded-none text-sm py-4 h-auto justify-start gap-3 text-sm text-left hover:bg-accent"
>
<LogoSupabase size={16} />
<span className="flex-1">Open in Supabase</span>
<ExternalLink className="ml-2 h-4 w-4 text-muted-foreground/50" />
</Link>
</HoverCardTrigger>
<HoverCardContent
sideOffset={8}
align="start"
side="top"
className="text-sm bg-muted/50 w-[216px]"
>
<h4 className="font-semibold mb-1">About Supabase</h4>
<p className="text-muted-foreground">
Access powerful back-end tools for database, auth, storage, and logs directly in
Supabase.
</p>
</HoverCardContent>
</HoverCard>
</footer>
</div>
{/* Content Area */}
<div className="flex flex-col overflow-hidden h-full">
{/* Header with breadcrumbs */}
<div className="flex items-center h-12 shrink-0 px-4 relative border-b">
{stack.length > 1 && (
<Button
variant="outline"
size="icon"
className="h-8 w-8 relative z-10"
onClick={() => popTo(stack.length - 2)}
>
<ChevronLeft className="h-4 w-4" />
<span className="sr-only">Back</span>
</Button>
)}
{/* Breadcrumbs */}
<div className="ml-4 flex items-center gap-1.5 text-sm text-muted-foreground relative z-10">
{stack.map((item: { title: string }, index: number) => (
<div key={`${item.title}-${index}`} className="flex items-center gap-1.5">
{index > 0 && <ChevronRight className="h-3 w-3" />}
{index === stack.length - 1 ? (
<span className="font-semibold text-foreground">{item.title}</span>
) : (
<button onClick={() => popTo(index)} className="hover:underline">
{item.title}
</button>
)}
</div>
))}
</div>
</div>
<div className="grow overflow-y-auto">
{currentView ? (
currentView.component
) : (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">
Select a manager from the sidebar to get started.
</p>
</div>
)}
</div>
</div>
</div>
)
}
export default function SupabaseManagerDialog({
projectRef,
open,
onOpenChange,
isMobile,
}: {
projectRef: string
open: boolean
onOpenChange: (open: boolean) => void
isMobile: boolean
}) {
const content = (
<SheetNavigationProvider
onStackEmpty={() => {}}
initialStack={[
{
title: 'Database',
component: <DatabaseManager projectRef={projectRef} />,
},
]}
>
<DialogView projectRef={projectRef} isMobile={isMobile} />
</SheetNavigationProvider>
)
if (!isMobile) {
return (
<QueryClientProvider client={queryClient}>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-[80vh] max-h-[700px] sm:max-w-[calc(100%-2rem)] w-[1180px] p-0 overflow-hidden sm:rounded-lg">
<DialogTitle className="sr-only">Manage your back-end</DialogTitle>
{content}
</DialogContent>
</Dialog>
</QueryClientProvider>
)
}
return (
<QueryClientProvider client={queryClient}>
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-[90vh] p-0 overflow-hidden">
<DrawerHeader className="sr-only">
<DrawerTitle>Manage your back-end</DrawerTitle>
</DrawerHeader>
{content}
</DrawerContent>
</Drawer>
</QueryClientProvider>
)
}
@@ -0,0 +1,268 @@
'use client'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { useGetLogs } from '@/hooks/use-logs'
import { LogsTableName, genDefaultQuery } from '../../lib/logs'
import { cn } from '@/lib/utils'
import { Check, ChevronsUpDown, Logs, Terminal } from 'lucide-react'
import { useMemo, useState } from 'react'
// Define log types with names and descriptions
const logTypes = [
{
value: LogsTableName.FN_EDGE,
label: 'Function Edge Logs',
description: 'Edge function execution logs with request and response metadata',
},
{
value: LogsTableName.AUTH,
label: 'Authentication Logs',
description: 'User authentication events and security logs',
},
{
value: LogsTableName.POSTGRES,
label: 'PostgreSQL Logs',
description: 'Database queries, errors, and performance metrics',
},
{
value: LogsTableName.REALTIME,
label: 'Realtime Logs',
description: 'WebSocket connections and realtime subscriptions',
},
{
value: LogsTableName.STORAGE,
label: 'Storage Logs',
description: 'File uploads, downloads, and storage operations',
},
{
value: LogsTableName.PG_CRON,
label: 'Cron Job Logs',
description: 'Scheduled job executions and cron task logs',
},
{
value: LogsTableName.EDGE,
label: 'Edge Logs',
description: 'HTTP requests and responses from the data API',
},
{
value: LogsTableName.FUNCTIONS,
label: 'Function Logs',
description: 'Serverless function execution logs and events',
},
{
value: LogsTableName.POSTGREST,
label: 'PostgREST Logs',
description: 'API requests to your database through PostgREST',
},
{
value: LogsTableName.SUPAVISOR,
label: 'Supavisor Logs',
description: 'Connection pooling and database proxy logs',
},
{
value: LogsTableName.PGBOUNCER,
label: 'PgBouncer Logs',
description: 'Legacy connection pooling logs',
},
{
value: LogsTableName.PG_UPGRADE,
label: 'PostgreSQL Upgrade Logs',
description: 'Database upgrade processes and migration logs',
},
]
export function LogsManager({ projectRef }: { projectRef: string }) {
const [activeTab, setActiveTab] = useState<LogsTableName>(LogsTableName.FN_EDGE)
const [open, setOpen] = useState(false)
const sql = useMemo(() => genDefaultQuery(activeTab), [activeTab])
const { data: logs, isLoading, error } = useGetLogs(projectRef, { sql })
const selectedLogType = logTypes.find((type) => type.value === activeTab)
return (
<>
<div className="p-6 pt-4 lg:p-8 lg:pt-8 border-b bg-background z-10 sticky top-0 flex items-center justify-between mb-6">
<div className="flex-1">
<h1 className="text-base lg:text-xl font-semibold">Logs</h1>
<p className="hidden lg:block text-sm lg:text-base text-muted-foreground mt-1">
Debug errors and track activity in your app
</p>
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-64 justify-between"
>
{selectedLogType ? selectedLogType.label : 'Select log type...'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="end">
<Command>
<CommandInput placeholder="Search log types..." className="h-9" />
<CommandList
className="max-h-60 overflow-y-auto"
onWheel={(e) => {
e.stopPropagation()
}}
style={{ overscrollBehavior: 'contain' }}
>
<CommandEmpty>No log type found.</CommandEmpty>
<CommandGroup>
{logTypes.map((logType) => (
<CommandItem
key={logType.value}
value={logType.value}
onSelect={(currentValue) => {
setActiveTab(currentValue as LogsTableName)
setOpen(false)
}}
className="flex items-center gap-2 p-3"
>
<div className="flex-1">
<div className="text-xs mb-1 font-medium leading-none">{logType.label}</div>
<div className="text-xs text-muted-foreground leading-snug">
{logType.description}
</div>
</div>
<Check
className={cn(
'h-4 w-4 mt-0.5',
activeTab === logType.value ? 'opacity-100' : 'opacity-0'
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{isLoading && (
<div className="space-y-2 mx-8 mt-8">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
</div>
)}
{(error || (logs && logs.error)) && (
<div className="mx-6 lg:mx-8 mt-8">
<Alert variant="destructive">
<Terminal className="h-4 w-4" />
<AlertTitle>Error fetching logs</AlertTitle>
<AlertDescription>
{(error as any)?.message ||
(typeof logs?.error === 'object' && logs.error?.message) ||
'An unexpected error occurred. Please try again.'}
</AlertDescription>
</Alert>
</div>
)}
{logs && logs.result && logs.result.length > 0 && (
<div className="mt-4 overflow-auto w-full">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
{Object.keys(logs.result[0] as object).map((key, idx, arr) => (
<TableHead
key={key}
className={
(idx === 0 ? 'first:pl-6 lg:first:pl-8 ' : '') +
(idx === arr.length - 1 ? 'last:pr-6 lg:last:pr-8 ' : '')
}
>
{key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{(logs.result as any[]).map((log, index) => (
<TableRow key={log.id || index} className="group hover:bg-muted/50 relative">
{Object.keys(logs.result?.[0] ?? []).map((key, idx, arr) => {
const value = log[key]
const formattedValue = (() => {
if (key === 'timestamp' && typeof value === 'number') {
return new Date(value / 1000).toLocaleString()
}
if (value === null) {
return 'NULL'
}
return typeof value === 'object'
? JSON.stringify(value, null, 2)
: String(value)
})()
return (
<TableCell
key={key}
className="first:pl-6 lg:first:pl-8 last:pr-6 lg:last:pr-8 text-xs text-muted-foreground group-hover:text-foreground min-w-[8rem]"
>
<HoverCard>
<HoverCardTrigger asChild>
<div className="text-xs font-mono w-fit max-w-96 truncate cursor-default">
{formattedValue}
</div>
</HoverCardTrigger>
<HoverCardContent className="w-96 max-h-96 overflow-auto p-3">
<pre className="text-xs font-mono whitespace-pre-wrap break-words">
{formattedValue}
</pre>
</HoverCardContent>
</HoverCard>
</TableCell>
)
})}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{logs && logs.result && logs.result.length === 0 && (
<div className="mt-8 mx-8">
<Alert>
<Logs className="h-4 w-4" />
<AlertTitle>No logs found</AlertTitle>
<AlertDescription>
Logs will appear here when your application generates activity.
</AlertDescription>
</Alert>
</div>
)}
</>
)
}
@@ -0,0 +1,195 @@
'use client'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import {
useCreateSecrets,
useDeleteSecrets,
useGetSecrets,
} from '@/hooks/use-secrets'
import { secretsSchema } from '../../lib/schemas/secrets'
import { zodResolver } from '@hookform/resolvers/zod'
import { AlertTriangle, Minus, PlusIcon, Key } from 'lucide-react'
import { useFieldArray, useForm } from 'react-hook-form'
import { z } from 'zod'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
export function SecretsManager({ projectRef }: { projectRef: string }) {
const { data: secrets, isLoading, error } = useGetSecrets(projectRef)
const { mutate: createSecrets, isPending: isCreating } = useCreateSecrets()
const { mutate: deleteSecrets, isPending: isDeleting } = useDeleteSecrets()
const form = useForm<z.infer<typeof secretsSchema>>({
resolver: zodResolver(secretsSchema),
defaultValues: {
secrets: [{ name: '', value: '' }],
},
})
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'secrets',
})
const handleCreateSecrets = (formData: z.infer<typeof secretsSchema>) => {
createSecrets(
{
projectRef,
secrets: formData.secrets,
},
{
onSuccess: () => {
form.reset({ secrets: [{ name: '', value: '' }] })
},
}
)
}
const handleDeleteSecret = (secretName: string) => {
if (window.confirm(`Are you sure you want to delete the secret "${secretName}"?`)) {
deleteSecrets({
projectRef,
secretNames: [secretName],
})
}
}
if (isLoading) {
return (
<div className="w-full max-w-4xl mx-auto p-12 space-y-4">
<Skeleton className="h-48 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
)
}
if (error) {
return (
<div className="mx-6 lg:mx-8 mt-8">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error loading secrets</AlertTitle>
<AlertDescription>
There was a problem loading your secrets configuration.
</AlertDescription>
</Alert>
</div>
)
}
return (
<div className="w-full max-w-3xl mx-auto p-6 pt-4 lg:p-12 lg:pt-12">
<div className="mb-16">
<h2 className="text-base lg:text-xl font-semibold mb-1">Add New Secret</h2>
<p className="text-muted-foreground mb-6 text-sm lg:text-base">
Add sensitive information like API keys that your app needs to work securely.
</p>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleCreateSecrets)} className="space-y-2">
{fields.map((field, index) => (
<div key={field.id} className="flex items-start space-x-2">
<FormField
control={form.control}
name={`secrets.${index}.name`}
render={({ field }) => (
<FormItem className="flex-1">
{index === 0 && <FormLabel className="mb-4">Name</FormLabel>}
<FormControl>
<Input placeholder="SECRET_NAME" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`secrets.${index}.value`}
render={({ field }) => (
<FormItem className="flex-1">
{index === 0 && <FormLabel className="mb-4">Value</FormLabel>}
<FormControl>
<Input type="password" placeholder="••••••••" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="w-8">
{index === 0 && <FormLabel className="opacity-0 mb-4">Remove</FormLabel>}
<Button
type="button"
variant="outline"
size="icon"
onClick={() => remove(index)}
disabled={fields.length <= 1}
>
<Minus className="h-4 w-4" />
</Button>
</div>
</div>
))}
<div className="flex items-center space-x-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ name: '', value: '' })}
>
<PlusIcon className="mr-2 h-4 w-4" />
Add Secret
</Button>
</div>
<Button type="submit" disabled={isCreating} className="mt-6">
{isCreating ? 'Creating...' : 'Create Secrets'}
</Button>
</form>
</Form>
</div>
<div>
<h2 className="font-semibold mb-4 lg:text-lg">Existing secrets</h2>
{secrets && secrets.length > 0 ? (
secrets.map((secret) => (
<div
key={secret.name}
className="flex items-center justify-between py-4 border-b last:border-b-0"
>
<div>
<p className="font-mono text-sm tracking-wider">{secret.name}</p>
{secret.updated_at && (
<p className="text-xs text-muted-foreground mt-1">
Last updated: {new Date(secret.updated_at).toLocaleString()}
</p>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteSecret(secret.name)}
disabled={isDeleting}
>
Delete
</Button>
</div>
))
) : (
<Alert>
<Key className="h-4 w-4" />
<AlertTitle>No secrets found</AlertTitle>
<AlertDescription>No secrets found for this project.</AlertDescription>
</Alert>
)}
</div>
</div>
)
}
@@ -0,0 +1,77 @@
'use client'
import { useGetBuckets } from '@/hooks/use-storage'
import { Skeleton } from '@/components/ui/skeleton'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { AlertTriangle, Folder } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
export function StorageManager({ projectRef }: { projectRef: string }) {
const { data: buckets, isLoading, isError } = useGetBuckets(projectRef)
return (
<div className="p-6 pt-4 lg:p-8 lg:pt-8">
<h1 className="text-base lg:text-xl font-semibold">Storage</h1>
<p className="hidden lg:block text-sm lg:text-base text-muted-foreground mt-1">
View and manage the files stored in your app.
</p>
{isLoading && (
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</div>
)}
{isError && (
<Alert variant="destructive" className="mt-8">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error loading buckets</AlertTitle>
<AlertDescription>There was a problem loading your storage buckets.</AlertDescription>
</Alert>
)}
{buckets && buckets.length > 0 ? (
<div className="mt-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{buckets.map((bucket: any) => (
<Tooltip key={bucket.id}>
<TooltipTrigger asChild>
<Button
key={bucket.id}
variant="outline"
className="flex-row justify-start text-left h-auto p-4 gap-4"
>
<Folder className="h-4 w-4 text-muted-foreground" />
<div className="flex-1">
<h2 className="font-semibold mb-1">{bucket.name}</h2>
<p className="text-xs text-muted-foreground">
Updated {new Date(bucket.updated_at).toLocaleDateString()}
</p>
</div>
<Badge variant={bucket.public ? 'default' : 'secondary'}>
{bucket.public ? 'Public' : 'Private'}
</Badge>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Viewing files is coming soon</p>
</TooltipContent>
</Tooltip>
))}
</div>
) : buckets && buckets.length === 0 ? (
<Alert className="mt-8">
<Folder className="h-4 w-4" />
<AlertTitle>No storage buckets</AlertTitle>
<AlertDescription>
A bucket is a container used to store and protect files in your app.
</AlertDescription>
</Alert>
) : null}
</div>
)
}
@@ -0,0 +1,121 @@
'use client'
import { useGetSuggestions } from '@/hooks/use-suggestions'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Terminal } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { useMemo } from 'react'
import ReactMarkdown from 'react-markdown'
import { Skeleton } from '@/components/ui/skeleton'
export function SuggestionsManager({ projectRef }: { projectRef: string }) {
const { data: suggestions, isLoading, error } = useGetSuggestions(projectRef)
const sortedSuggestions = useMemo(() => {
if (!suggestions) return []
const levelOrder = { ERROR: 1, WARN: 2, INFO: 3 }
return [...suggestions].sort((a: any, b: any) => {
const levelA = levelOrder[a.level as keyof typeof levelOrder] || 99
const levelB = levelOrder[b.level as keyof typeof levelOrder] || 99
return levelA - levelB
})
}, [suggestions])
const getBadgeVariant = (level: 'ERROR' | 'WARN' | 'INFO') => {
switch (level) {
case 'ERROR':
return 'destructive'
case 'WARN':
return 'secondary'
default:
return 'outline'
}
}
return (
<div className="p-6 pt-4 lg:p-12 lg:pt-12 max-w-3xl mx-auto">
<h2 className="text-base lg:text-xl font-semibold mb-1">Suggestions</h2>
<p className="text-muted-foreground mb-4 lg:mb-8 text-sm lg:text-base">
Improve your project&apos;s security and performance.
</p>
{isLoading && (
<div className="space-y-2">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
</div>
)}
{error && (
<Alert variant="destructive" className="mt-8">
<Terminal className="h-4 w-4" />
<AlertTitle>Error fetching suggestions</AlertTitle>
<AlertDescription>
{(error as any)?.message || 'An unexpected error occurred. Please try again.'}
</AlertDescription>
</Alert>
)}
{suggestions && (
<div>
{sortedSuggestions.length > 0 ? (
<div>
{sortedSuggestions.map((suggestion: any) => (
<div
key={suggestion.cache_key}
className="py-4 border-b last:border-b-0 group relative"
>
<div className="flex-1">
<div className="flex justify-start items-start gap-4">
<h4 className="font-semibold text-sm">{suggestion.title}</h4>
<div className="flex items-center gap-1">
<Badge variant={getBadgeVariant(suggestion.level)} className="shrink-0">
{suggestion.level}
</Badge>
{suggestion.type && (
<Badge
variant={suggestion.type === 'security' ? 'destructive' : 'secondary'}
className="shrink-0"
>
{suggestion.type.charAt(0).toUpperCase() + suggestion.type.slice(1)}
</Badge>
)}
</div>
</div>
<div className="text-sm text-muted-foreground mt-2 prose prose-sm max-w-none">
<ReactMarkdown
components={{
code({ inline, children, ...props }: any) {
return inline ? (
<code className="bg-muted px-1 rounded" {...props}>
{children}
</code>
) : (
<pre className="bg-muted p-2 rounded overflow-x-auto" {...props}>
<code>{children}</code>
</pre>
)
},
}}
>
{suggestion.detail}
</ReactMarkdown>
</div>
</div>
</div>
))}
</div>
) : (
<Alert>
<Terminal className="h-4 w-4" />
<AlertTitle>No suggestions found</AlertTitle>
<AlertDescription>
Your project looks good! No suggestions at this time.
</AlertDescription>
</Alert>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,59 @@
'use client'
import { SqlEditor } from '@/components/sql-editor'
import { useState } from 'react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { UsersGrowthChart } from '@/components/users-growth-chart'
export function UsersManager({ projectRef }: { projectRef: string }) {
const [timeRange, setTimeRange] = useState(90)
const defaultSql = `SELECT * FROM auth.users ORDER BY created_at DESC LIMIT 100;`
return (
<div className="pb-8">
<div className="flex items-center justify-between p-6 pt-4 lg:p-8 lg:pt-8">
<div className="flex-1">
<h1 className="text-base lg:text-xl font-semibold">Users</h1>
<p className="hidden lg:block text-sm lg:text-base text-muted-foreground mt-1">
View user signups over time
</p>
</div>
<Select value={String(timeRange)} onValueChange={(value) => setTimeRange(Number(value))}>
<SelectTrigger className="w-[160px] rounded-lg sm:ml-auto" aria-label="Select a value">
<SelectValue placeholder="Select time range" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem value="90" className="rounded-lg">
Last 90 days
</SelectItem>
<SelectItem value="30" className="rounded-lg">
Last 30 days
</SelectItem>
<SelectItem value="7" className="rounded-lg">
Last 7 days
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="px-8">
<UsersGrowthChart projectRef={projectRef} timeRange={timeRange} />
</div>
<SqlEditor
hideChartOption
projectRef={projectRef}
initialSql={defaultSql}
initialNaturalLanguageMode={true}
label="Recent users"
hideSql={true}
readOnly={true}
runAutomatically={true}
/>
</div>
)
}
@@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }
@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
@@ -0,0 +1,369 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
@@ -0,0 +1,153 @@
"use client"
import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
@@ -0,0 +1,118 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
@@ -0,0 +1,178 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
if (!itemContext) {
throw new Error("useFormField should be used within <FormItem>")
}
const fieldState = getFieldState(fieldContext.name, formState)
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue | null>(null)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-sm font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}
@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }
@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
@@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-muted", className)}
{...props}
/>
)
}
export { Skeleton }
@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }
@@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }
@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-10 px-3 min-w-10",
sm: "h-9 px-2.5 min-w-9",
lg: "h-11 px-5 min-w-11",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -0,0 +1,95 @@
'use client'
import * as React from 'react'
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from '@/components/ui/chart'
import { useGetUserCountsByDay } from '@/hooks/use-user-counts'
import { Skeleton } from '@/components/ui/skeleton'
import { AlertTriangle } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
const chartConfig = {
users: {
label: 'New Users',
color: 'var(--chart-1)',
},
} satisfies ChartConfig
export function UsersGrowthChart({
projectRef,
timeRange,
}: {
projectRef: string
timeRange: number
}) {
const { data: chartData, isLoading, isError } = useGetUserCountsByDay(projectRef, timeRange)
return (
<div>
{isLoading && <Skeleton className="h-[250px] w-full" />}
{isError && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error loading chart data</AlertTitle>
<AlertDescription>There was a problem loading your chart data.</AlertDescription>
</Alert>
)}
{chartData && !isLoading && (
<ChartContainer config={chartConfig} className="aspect-auto h-[250px] w-full">
<BarChart
accessibilityLayer
data={chartData}
margin={{
left: -24,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value)
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})
}}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
tickCount={5}
allowDecimals={false}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[150px]"
labelFormatter={(value) => {
return new Date(value).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}}
indicator="dot"
/>
}
/>
<Bar dataKey="users" fill="var(--color-users)" />
</BarChart>
</ChartContainer>
)}
</div>
)
}
@@ -0,0 +1,69 @@
'use client'
import { createContext, useContext, useState, ReactNode, useMemo, useCallback } from 'react'
type StackItem = {
title: string
component: ReactNode
}
type SheetNavigationContextType = {
stack: StackItem[]
push: (item: StackItem) => void
pop: () => void
popTo: (index: number) => void
reset: () => void
}
const SheetNavigationContext = createContext<SheetNavigationContextType | undefined>(undefined)
export function SheetNavigationProvider({
children,
onStackEmpty,
initialStack = [],
}: {
children: ReactNode
onStackEmpty?: () => void
initialStack?: StackItem[]
}) {
const [stack, setStack] = useState<StackItem[]>(initialStack)
const push = useCallback((item: StackItem) => {
setStack((prev) => [...prev, item])
}, [])
const pop = useCallback(() => {
setStack((prev) => {
const newStack = prev.slice(0, -1)
if (newStack.length === 0) {
onStackEmpty?.()
}
return newStack
})
}, [onStackEmpty])
const popTo = useCallback((index: number) => {
if (index < 0) return
setStack((prev) => prev.slice(0, index + 1))
}, [])
const reset = useCallback(() => {
setStack([])
onStackEmpty?.()
}, [onStackEmpty])
const value = useMemo(
() => ({ stack, push, pop, popTo, reset }),
[stack, push, pop, popTo, reset]
)
return <SheetNavigationContext.Provider value={value}>{children}</SheetNavigationContext.Provider>
}
export function useSheetNavigation() {
const context = useContext(SheetNavigationContext)
if (context === undefined) {
throw new Error('useSheetNavigation must be used within a SheetNavigationProvider')
}
return context
}
+68
View File
@@ -0,0 +1,68 @@
'use client'
import { client } from '@/lib/management-api'
import type { components } from '@/lib/management-api-schema'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { type AxiosError } from 'axios'
import { toast } from 'sonner'
const getAuthConfig = async (projectRef: string) => {
const { data, error } = await client.GET('/v1/projects/{ref}/config/auth', {
params: {
path: { ref: projectRef },
},
})
if (error) {
throw error
}
return data
}
export const useGetAuthConfig = (projectRef: string) => {
return useQuery({
queryKey: ['auth-config', projectRef],
queryFn: () => getAuthConfig(projectRef),
enabled: !!projectRef,
retry: false,
})
}
// UPDATE Auth Config
const updateAuthConfig = async ({
projectRef,
payload,
}: {
projectRef: string
payload: components['schemas']['UpdateAuthConfigBody']
}) => {
const { data, error } = await client.PATCH('/v1/projects/{ref}/config/auth', {
params: {
path: {
ref: projectRef,
},
},
body: payload,
})
if (error) {
throw error
}
return data
}
export const useUpdateAuthConfig = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: updateAuthConfig,
onSuccess: (data, variables) => {
toast.success(`Auth config updated.`)
queryClient.invalidateQueries({
queryKey: ['auth-config', variables.projectRef],
})
},
onError: (error: AxiosError<{ message: string }>) => {
toast.error(error.response?.data?.message || 'There was a problem with your request.')
},
})
}
+63
View File
@@ -0,0 +1,63 @@
'use client'
import { client } from '@/lib/management-api'
import { useQuery } from '@tanstack/react-query'
// GET Logs
const getLogs = async ({
projectRef,
iso_timestamp_start,
iso_timestamp_end,
sql,
}: {
projectRef: string
iso_timestamp_start?: string
iso_timestamp_end?: string
sql?: string
}) => {
const { data, error } = await client.GET('/v1/projects/{ref}/analytics/endpoints/logs.all', {
params: {
path: {
ref: projectRef,
},
query: {
iso_timestamp_start,
iso_timestamp_end,
sql,
},
},
})
if (error) {
throw error
}
return data
}
export const useGetLogs = (
projectRef: string,
params: {
iso_timestamp_start?: string
iso_timestamp_end?: string
sql?: string
} = {}
) => {
const queryKey = ['logs', projectRef, params.sql]
return useQuery({
queryKey: queryKey,
queryFn: () => {
const now = new Date()
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000)
const queryParams = {
sql: params.sql,
iso_timestamp_start: params.iso_timestamp_start ?? oneHourAgo.toISOString(),
iso_timestamp_end: params.iso_timestamp_end ?? now.toISOString(),
}
return getLogs({ projectRef, ...queryParams })
},
enabled: !!projectRef,
retry: false,
})
}
@@ -0,0 +1,44 @@
'use client'
import { client } from '@/lib/management-api'
import { useMutation } from '@tanstack/react-query'
import { type AxiosError } from 'axios'
import { toast } from 'sonner'
// RUN SQL Query
export const runQuery = async ({
projectRef,
query,
readOnly,
}: {
projectRef: string
query: string
readOnly?: boolean
}) => {
const { data, error } = await client.POST('/v1/projects/{ref}/database/query', {
params: {
path: {
ref: projectRef,
},
},
body: {
query,
read_only: readOnly,
},
})
if (error) {
throw error
}
return data as any
}
export const useRunQuery = () => {
return useMutation({
mutationFn: runQuery,
onError: (error: AxiosError<{ message: string }>) => {
toast.error(error.response?.data?.message || 'There was a problem with your query.')
},
})
}
+110
View File
@@ -0,0 +1,110 @@
'use client'
import { client } from '@/lib/management-api'
import type { components } from '@/lib/management-api-schema'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { type AxiosError } from 'axios'
import { toast } from 'sonner'
// GET Secrets
const getSecrets = async (projectRef: string) => {
const { data, error } = await client.GET('/v1/projects/{ref}/secrets', {
params: {
path: {
ref: projectRef,
},
},
})
if (error) {
throw error
}
return data
}
export const useGetSecrets = (projectRef: string) => {
return useQuery({
queryKey: ['secrets', projectRef],
queryFn: () => getSecrets(projectRef),
enabled: !!projectRef,
retry: false,
})
}
// CREATE Secrets
const createSecrets = async ({
projectRef,
secrets,
}: {
projectRef: string
secrets: components['schemas']['CreateSecretBody']
}) => {
const { data, error } = await client.POST('/v1/projects/{ref}/secrets', {
params: {
path: {
ref: projectRef,
},
},
body: secrets,
})
if (error) {
throw error
}
return data
}
export const useCreateSecrets = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createSecrets,
onSuccess: (data, variables) => {
toast.success(`Secrets created successfully.`)
queryClient.refetchQueries({
queryKey: ['secrets', variables.projectRef],
})
},
onError: (error: AxiosError<{ message: string }>) => {
toast.error(error.response?.data?.message || 'There was a problem with your request.')
},
})
}
// DELETE Secrets
const deleteSecrets = async ({
projectRef,
secretNames,
}: {
projectRef: string
secretNames: string[]
}) => {
const { data, error } = await client.DELETE('/v1/projects/{ref}/secrets', {
params: {
path: {
ref: projectRef,
},
},
body: secretNames,
})
if (error) {
throw error
}
return data
}
export const useDeleteSecrets = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteSecrets,
onSuccess: (data, variables) => {
toast.success(`Secrets deleted successfully.`)
queryClient.invalidateQueries({
queryKey: ['secrets', variables.projectRef],
})
},
onError: (error: AxiosError<{ message: string }>) => {
toast.error(error.response?.data?.message || 'There was a problem with your request.')
},
})
}
@@ -0,0 +1,63 @@
'use client'
import { client } from '@/lib/management-api'
import { useQuery } from '@tanstack/react-query'
// GET Buckets
const getBuckets = async (projectRef: string) => {
const { data, error } = await client.GET('/v1/projects/{ref}/storage/buckets', {
params: {
path: {
ref: projectRef,
},
},
})
if (error) {
throw error
}
return data
}
export const useGetBuckets = (projectRef: string) => {
return useQuery({
queryKey: ['buckets', projectRef],
queryFn: () => getBuckets(projectRef),
enabled: !!projectRef,
retry: false,
})
}
// LIST Objects
const listObjects = async ({ projectRef, bucketId }: { projectRef: string; bucketId: string }) => {
const { data, error } = await client.POST(
// TODO
// @ts-expect-error this endpoint is not yet implemented
'/v1/projects/{ref}/storage/buckets/{bucketId}/objects/list',
{
params: {
path: {
ref: projectRef,
bucketId,
},
},
body: {
path: '',
options: { limit: 100, offset: 0 },
},
}
)
if (error) {
throw error
}
return data as any
}
export const useListObjects = (projectRef: string, bucketId: string) => {
return useQuery({
queryKey: ['objects', projectRef, bucketId],
queryFn: () => listObjects({ projectRef, bucketId }),
enabled: !!projectRef && !!bucketId,
})
}
@@ -0,0 +1,53 @@
'use client'
import { client } from '@/lib/management-api'
import { useQuery } from '@tanstack/react-query'
// GET Suggestions
const getSuggestions = async (projectRef: string) => {
const [
{ data: performanceData, error: performanceError },
{ data: securityData, error: securityError },
] = await Promise.all([
client.GET('/v1/projects/{ref}/advisors/performance', {
params: {
path: {
ref: projectRef,
},
},
}),
client.GET('/v1/projects/{ref}/advisors/security', {
params: {
path: {
ref: projectRef,
},
},
}),
])
if (performanceError) {
throw performanceError
}
if (securityError) {
throw securityError
}
// Add type to each suggestion
const performanceLints = (performanceData?.lints || []).map((lint) => ({
...lint,
type: 'performance' as const,
}))
const securityLints = (securityData?.lints || []).map((lint) => ({
...lint,
type: 'security' as const,
}))
return [...performanceLints, ...securityLints]
}
export const useGetSuggestions = (projectRef: string) => {
return useQuery({
queryKey: ['suggestions', projectRef],
queryFn: () => getSuggestions(projectRef),
enabled: !!projectRef,
retry: false,
})
}
+23
View File
@@ -0,0 +1,23 @@
'use client'
import { listTablesSql } from '@/lib/pg-meta'
import { runQuery } from '@/hooks/use-run-query'
import { useQuery } from '@tanstack/react-query'
// LIST Tables
const listTables = ({ projectRef, schemas }: { projectRef: string; schemas?: string[] }) => {
const sql = listTablesSql(schemas)
return runQuery({
projectRef,
query: sql,
readOnly: true,
})
}
export const useListTables = (projectRef: string, schemas?: string[]) => {
return useQuery({
queryKey: ['tables', projectRef, schemas],
queryFn: () => listTables({ projectRef, schemas }),
enabled: !!projectRef,
})
}
@@ -0,0 +1,47 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { runQuery } from '@/hooks/use-run-query'
// GET User Counts by day
const getUserCountsByDay = ({ projectRef, days }: { projectRef: string; days: number }) => {
const sql = `
WITH days_series AS (
SELECT generate_series(
date_trunc('day', now() - interval '${Number(days) - 1} days'),
date_trunc('day', now()),
'1 day'::interval
)::date AS date
)
SELECT
d.date,
COALESCE(u.users, 0)::int as users
FROM
days_series d
LEFT JOIN (
SELECT
date_trunc('day', created_at AT TIME ZONE 'UTC')::date as date,
count(id) as users
FROM
auth.users
GROUP BY 1
) u ON d.date = u.date
ORDER BY
d.date ASC;
`
return runQuery({
projectRef,
query: sql,
readOnly: true,
})
}
export const useGetUserCountsByDay = (projectRef: string, days: number) => {
return useQuery({
queryKey: ['user-counts', projectRef, days],
queryFn: () => getUserCountsByDay({ projectRef, days }),
enabled: !!projectRef,
retry: false,
})
}
+110
View File
@@ -0,0 +1,110 @@
export enum LogsTableName {
FN_EDGE = 'function_edge_logs',
AUTH = 'auth_logs',
POSTGRES = 'postgres_logs',
REALTIME = 'realtime_logs',
STORAGE = 'storage_logs',
PG_CRON = 'pg_cron_logs',
EDGE = 'edge_logs',
FUNCTIONS = 'function_logs',
POSTGREST = 'postgrest_logs',
SUPAVISOR = 'supavisor_logs',
PGBOUNCER = 'pgbouncer_logs',
PG_UPGRADE = 'pg_upgrade_logs',
}
const genCrossJoinUnnests = (table: LogsTableName) => {
switch (table) {
case LogsTableName.EDGE:
return `cross join unnest(metadata) as m
cross join unnest(m.request) as request
cross join unnest(m.response) as response`
case LogsTableName.POSTGRES:
return `cross join unnest(metadata) as m
cross join unnest(m.parsed) as parsed`
case LogsTableName.FUNCTIONS:
return `cross join unnest(metadata) as metadata`
case LogsTableName.AUTH:
return `cross join unnest(metadata) as metadata`
case LogsTableName.FN_EDGE:
return `cross join unnest(metadata) as m
cross join unnest(m.response) as response
cross join unnest(m.request) as request`
case LogsTableName.SUPAVISOR:
return `cross join unnest(metadata) as m`
default:
return ''
}
}
export const genDefaultQuery = (table: LogsTableName, limit: number = 100) => {
const joins = genCrossJoinUnnests(table)
const orderBy = 'order by timestamp desc'
switch (table) {
case LogsTableName.EDGE:
return `select id, ${table}.timestamp, event_message, request.method, request.path, request.search, response.status_code
from ${table}
${joins}
${orderBy}
limit ${limit}`
case LogsTableName.POSTGRES:
return `select ${table}.timestamp, id, event_message, parsed.error_severity, parsed.detail, parsed.hint
from ${table}
${joins}
${orderBy}
limit ${limit}`
case LogsTableName.FUNCTIONS:
return `select id, ${table}.timestamp, event_message, metadata.event_type, metadata.function_id, metadata.level
from ${table}
${joins}
${orderBy}
limit ${limit}`
case LogsTableName.AUTH:
return `select id, ${table}.timestamp, event_message, metadata.level, metadata.status, metadata.path, metadata.msg as msg, metadata.error
from ${table}
${joins}
${orderBy}
limit ${limit}`
case LogsTableName.FN_EDGE:
return `select id, ${table}.timestamp, event_message, response.status_code, request.method, m.function_id, m.execution_time_ms, m.deployment_id, m.version
from ${table}
${joins}
${orderBy}
limit ${limit}`
case LogsTableName.SUPAVISOR:
return `select id, ${table}.timestamp, event_message
from ${table}
${joins}
${orderBy}
limit ${limit}`
case LogsTableName.PG_UPGRADE:
return `select id, ${table}.timestamp, event_message
from ${table}
${joins}
${orderBy}
limit ${limit}`
case LogsTableName.PG_CRON:
return `select postgres_logs.timestamp, id, event_message, parsed.error_severity, parsed.query
from postgres_logs
cross join unnest(metadata) as m
cross join unnest(m.parsed) as parsed
where (parsed.application_name = 'pg_cron' OR event_message LIKE '%cron job%')
${orderBy}
limit ${limit}`
default:
return `select id, ${table}.timestamp, event_message
from ${table}
${joins}
${orderBy}
limit ${limit}`
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,6 @@
import createClient from 'openapi-fetch'
import type { paths } from './management-api-schema'
export const client = createClient<paths>({
baseUrl: '/api/supabase-proxy',
})
@@ -0,0 +1,59 @@
import { stripIndent } from 'common-tags'
import { columnsSql, extensionsSql, tablesSql } from './sql'
export const SYSTEM_SCHEMAS = [
'information_schema',
'pg_catalog',
'pg_toast',
'_timescaledb_internal',
]
/**
* Generates the SQL query to list tables in the database.
*/
export function listTablesSql(schemas: string[] = []) {
let sql = stripIndent`
with
tables as (${tablesSql}),
columns as (${columnsSql})
select
*,
${coalesceRowsToArray('columns', 'columns.table_id = tables.id')}
from tables
`
sql += '\n'
if (schemas.length > 0) {
sql += `where schema in (${schemas.map((s) => `'${s}'`).join(',')})`
} else {
sql += `where schema not in (${SYSTEM_SCHEMAS.map((s) => `'${s}'`).join(',')})`
}
return sql
}
/**
* Generates the SQL query to list all extensions in the database.
*/
export function listExtensionsSql() {
return extensionsSql
}
/**
* Generates a SQL segment that coalesces rows into an array of JSON objects.
*/
export const coalesceRowsToArray = (source: string, filter: string) => {
return stripIndent`
COALESCE(
(
SELECT
array_agg(row_to_json(${source})) FILTER (WHERE ${filter})
FROM
${source}
),
'{}'
) AS ${source}
`
}
+225
View File
@@ -0,0 +1,225 @@
export const columnsSql = `
SELECT
c.oid :: int8 AS table_id,
nc.nspname AS schema,
c.relname AS table,
(c.oid || '.' || a.attnum) AS id,
a.attnum AS ordinal_position,
a.attname AS name,
CASE
WHEN a.atthasdef THEN pg_get_expr(ad.adbin, ad.adrelid)
ELSE NULL
END AS default_value,
CASE
WHEN t.typtype = 'd' THEN CASE
WHEN bt.typelem <> 0 :: oid
AND bt.typlen = -1 THEN 'ARRAY'
WHEN nbt.nspname = 'pg_catalog' THEN format_type(t.typbasetype, NULL)
ELSE 'USER-DEFINED'
END
ELSE CASE
WHEN t.typelem <> 0 :: oid
AND t.typlen = -1 THEN 'ARRAY'
WHEN nt.nspname = 'pg_catalog' THEN format_type(a.atttypid, NULL)
ELSE 'USER-DEFINED'
END
END AS data_type,
COALESCE(bt.typname, t.typname) AS format,
a.attidentity IN ('a', 'd') AS is_identity,
CASE
a.attidentity
WHEN 'a' THEN 'ALWAYS'
WHEN 'd' THEN 'BY DEFAULT'
ELSE NULL
END AS identity_generation,
a.attgenerated IN ('s') AS is_generated,
NOT (
a.attnotnull
OR t.typtype = 'd' AND t.typnotnull
) AS is_nullable,
(
c.relkind IN ('r', 'p')
OR c.relkind IN ('v', 'f') AND pg_column_is_updatable(c.oid, a.attnum, FALSE)
) AS is_updatable,
uniques.table_id IS NOT NULL AS is_unique,
check_constraints.definition AS "check",
array_to_json(
array(
SELECT
enumlabel
FROM
pg_catalog.pg_enum enums
WHERE
enums.enumtypid = coalesce(bt.oid, t.oid)
OR enums.enumtypid = coalesce(bt.typelem, t.typelem)
ORDER BY
enums.enumsortorder
)
) AS enums,
col_description(c.oid, a.attnum) AS comment
FROM
pg_attribute a
LEFT JOIN pg_attrdef ad ON a.attrelid = ad.adrelid
AND a.attnum = ad.adnum
JOIN (
pg_class c
JOIN pg_namespace nc ON c.relnamespace = nc.oid
) ON a.attrelid = c.oid
JOIN (
pg_type t
JOIN pg_namespace nt ON t.typnamespace = nt.oid
) ON a.atttypid = t.oid
LEFT JOIN (
pg_type bt
JOIN pg_namespace nbt ON bt.typnamespace = nbt.oid
) ON t.typtype = 'd'
AND t.typbasetype = bt.oid
LEFT JOIN (
SELECT DISTINCT ON (table_id, ordinal_position)
conrelid AS table_id,
conkey[1] AS ordinal_position
FROM pg_catalog.pg_constraint
WHERE contype = 'u' AND cardinality(conkey) = 1
) AS uniques ON uniques.table_id = c.oid AND uniques.ordinal_position = a.attnum
LEFT JOIN (
-- We only select the first column check
SELECT DISTINCT ON (table_id, ordinal_position)
conrelid AS table_id,
conkey[1] AS ordinal_position,
substring(
pg_get_constraintdef(pg_constraint.oid, true),
8,
length(pg_get_constraintdef(pg_constraint.oid, true)) - 8
) AS "definition"
FROM pg_constraint
WHERE contype = 'c' AND cardinality(conkey) = 1
ORDER BY table_id, ordinal_position, oid asc
) AS check_constraints ON check_constraints.table_id = c.oid AND check_constraints.ordinal_position = a.attnum
WHERE
NOT pg_is_other_temp_schema(nc.oid)
AND a.attnum > 0
AND NOT a.attisdropped
AND (c.relkind IN ('r', 'v', 'm', 'f', 'p'))
AND (
pg_has_role(c.relowner, 'USAGE')
OR has_column_privilege(
c.oid,
a.attnum,
'SELECT, INSERT, UPDATE, REFERENCES'
)
)
`
export const tablesSql = `
SELECT
c.oid :: int8 AS id,
nc.nspname AS schema,
c.relname AS name,
c.relrowsecurity AS rls_enabled,
c.relforcerowsecurity AS rls_forced,
CASE
WHEN c.relreplident = 'd' THEN 'DEFAULT'
WHEN c.relreplident = 'i' THEN 'INDEX'
WHEN c.relreplident = 'f' THEN 'FULL'
ELSE 'NOTHING'
END AS replica_identity,
pg_total_relation_size(format('%I.%I', nc.nspname, c.relname)) :: int8 AS bytes,
pg_size_pretty(
pg_total_relation_size(format('%I.%I', nc.nspname, c.relname))
) AS size,
pg_stat_get_live_tuples(c.oid) AS live_rows_estimate,
pg_stat_get_dead_tuples(c.oid) AS dead_rows_estimate,
obj_description(c.oid) AS comment,
coalesce(pk.primary_keys, '[]') as primary_keys,
coalesce(
jsonb_agg(relationships) filter (where relationships is not null),
'[]'
) as relationships
FROM
pg_namespace nc
JOIN pg_class c ON nc.oid = c.relnamespace
left join (
select
table_id,
jsonb_agg(_pk.*) as primary_keys
from (
select
n.nspname as schema,
c.relname as table_name,
a.attname as name,
c.oid :: int8 as table_id
from
pg_index i,
pg_class c,
pg_attribute a,
pg_namespace n
where
i.indrelid = c.oid
and c.relnamespace = n.oid
and a.attrelid = c.oid
and a.attnum = any (i.indkey)
and i.indisprimary
) as _pk
group by table_id
) as pk
on pk.table_id = c.oid
left join (
select
c.oid :: int8 as id,
c.conname as constraint_name,
nsa.nspname as source_schema,
csa.relname as source_table_name,
sa.attname as source_column_name,
nta.nspname as target_table_schema,
cta.relname as target_table_name,
ta.attname as target_column_name
from
pg_constraint c
join (
pg_attribute sa
join pg_class csa on sa.attrelid = csa.oid
join pg_namespace nsa on csa.relnamespace = nsa.oid
) on sa.attrelid = c.conrelid and sa.attnum = any (c.conkey)
join (
pg_attribute ta
join pg_class cta on ta.attrelid = cta.oid
join pg_namespace nta on cta.relnamespace = nta.oid
) on ta.attrelid = c.confrelid and ta.attnum = any (c.confkey)
where
c.contype = 'f'
) as relationships
on (relationships.source_schema = nc.nspname and relationships.source_table_name = c.relname)
or (relationships.target_table_schema = nc.nspname and relationships.target_table_name = c.relname)
WHERE
c.relkind IN ('r', 'p')
AND NOT pg_is_other_temp_schema(nc.oid)
AND (
pg_has_role(c.relowner, 'USAGE')
OR has_table_privilege(
c.oid,
'SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER'
)
OR has_any_column_privilege(c.oid, 'SELECT, INSERT, UPDATE, REFERENCES')
)
group by
c.oid,
c.relname,
c.relrowsecurity,
c.relforcerowsecurity,
c.relreplident,
nc.nspname,
pk.primary_keys
`
export const extensionsSql = `
SELECT
e.name,
n.nspname AS schema,
e.default_version,
x.extversion AS installed_version,
e.comment
FROM
pg_available_extensions() e(name, default_version, comment)
LEFT JOIN pg_extension x ON e.name = x.extname
LEFT JOIN pg_namespace n ON x.extnamespace = n.oid
`
@@ -0,0 +1,76 @@
import { z } from 'zod'
export const postgresPrimaryKeySchema = z.object({
schema: z.string(),
table_name: z.string(),
name: z.string(),
table_id: z.number().int(),
})
export const postgresRelationshipSchema = z.object({
id: z.number().int(),
constraint_name: z.string(),
source_schema: z.string(),
source_table_name: z.string(),
source_column_name: z.string(),
target_table_schema: z.string(),
target_table_name: z.string(),
target_column_name: z.string(),
})
export const postgresColumnSchema = z.object({
table_id: z.number().int(),
schema: z.string(),
table: z.string(),
id: z.string().regex(/^(\d+)\.(\d+)$/),
ordinal_position: z.number().int(),
name: z.string(),
default_value: z.any(),
data_type: z.string(),
format: z.string(),
is_identity: z.boolean(),
identity_generation: z.union([z.literal('ALWAYS'), z.literal('BY DEFAULT'), z.null()]),
is_generated: z.boolean(),
is_nullable: z.boolean(),
is_updatable: z.boolean(),
is_unique: z.boolean(),
enums: z.array(z.string()),
check: z.union([z.string(), z.null()]),
comment: z.union([z.string(), z.null()]),
})
export const postgresTableSchema = z.object({
id: z.number().int(),
schema: z.string(),
name: z.string(),
rls_enabled: z.boolean(),
rls_forced: z.boolean(),
replica_identity: z.union([
z.literal('DEFAULT'),
z.literal('INDEX'),
z.literal('FULL'),
z.literal('NOTHING'),
]),
bytes: z.number().int(),
size: z.string(),
live_rows_estimate: z.number().int(),
dead_rows_estimate: z.number().int(),
comment: z.string().nullable(),
columns: z.array(postgresColumnSchema).optional(),
primary_keys: z.array(postgresPrimaryKeySchema),
relationships: z.array(postgresRelationshipSchema),
})
export const postgresExtensionSchema = z.object({
name: z.string(),
schema: z.union([z.string(), z.null()]),
default_version: z.string(),
installed_version: z.union([z.string(), z.null()]),
comment: z.union([z.string(), z.null()]),
})
export type PostgresPrimaryKey = z.infer<typeof postgresPrimaryKeySchema>
export type PostgresRelationship = z.infer<typeof postgresRelationshipSchema>
export type PostgresColumn = z.infer<typeof postgresColumnSchema>
export type PostgresTable = z.infer<typeof postgresTableSchema>
export type PostgresExtension = z.infer<typeof postgresExtensionSchema>
+269
View File
@@ -0,0 +1,269 @@
import { z } from 'zod'
// Field labels mapping for form generation
export const authFieldLabels: Record<
string,
string | { label: string; options?: Record<string, string> }
> = {
// General Settings
disable_signup: 'Disable Signup',
external_anonymous_users_enabled: 'Allow Anonymous Sign-ins',
// Email Provider
external_email_enabled: 'Enable Email Provider',
mailer_autoconfirm: 'Confirm Email',
mailer_secure_email_change_enabled: 'Secure Email Change',
security_update_password_require_reauthentication: 'Secure Password Change',
password_hibp_enabled: 'Prevent Leaked Passwords',
password_min_length: 'Minimum Password Length',
password_required_characters: {
label: 'Password Requirements',
options: {
NO_REQUIRED_CHARS: 'No requirements',
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789': 'Letters and numbers',
'abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789':
'Lowercase, uppercase, and numbers',
'abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};\'\\\\:"|<>?,./`~':
'Letters, numbers, and symbols',
},
},
mailer_otp_exp: 'Email OTP Expiration (seconds)',
mailer_otp_length: 'Email OTP Length',
// Phone Provider
external_phone_enabled: 'Enable Phone Provider',
sms_provider: {
label: 'SMS Provider',
options: {
twilio: 'Twilio',
messagebird: 'MessageBird',
textlocal: 'TextLocal',
vonage: 'Vonage',
twilio_verify: 'Twilio Verify',
},
},
sms_twilio_account_sid: 'Twilio Account SID',
sms_twilio_auth_token: 'Twilio Auth Token',
sms_twilio_message_service_sid: 'Twilio Message Service SID',
sms_twilio_content_sid: 'Twilio Content SID',
sms_twilio_verify_account_sid: 'Twilio Verify Account SID',
sms_twilio_verify_auth_token: 'Twilio Verify Auth Token',
sms_twilio_verify_message_service_sid: 'Twilio Verify Message Service SID',
sms_messagebird_access_key: 'MessageBird Access Key',
sms_messagebird_originator: 'MessageBird Originator',
sms_textlocal_api_key: 'TextLocal API Key',
sms_textlocal_sender: 'TextLocal Sender',
sms_vonage_api_key: 'Vonage API Key',
sms_vonage_api_secret: 'Vonage API Secret',
sms_vonage_from: 'Vonage From Number',
sms_autoconfirm: 'Enable Phone Confirmations',
sms_otp_exp: 'SMS OTP Expiry (seconds)',
sms_otp_length: 'SMS OTP Length',
sms_template: 'SMS Message Template',
sms_test_otp: 'Test Phone Numbers and OTPs',
sms_test_otp_valid_until: 'Test OTPs Valid Until',
// Google Provider
external_google_enabled: 'Enable Google Sign-in',
external_google_client_id: 'Google Client ID(s)',
external_google_secret: 'Google OAuth Client Secret',
external_google_skip_nonce_check: 'Skip Nonce Check',
// Secrets
name: 'Secret Name',
value: 'Secret Value',
secretNames: 'Secret Names',
}
// New schemas for auth settings
export const authGeneralSettingsSchema = z.object({
disable_signup: z.boolean().optional().describe('This will prevent new users from signing up.'),
external_anonymous_users_enabled: z
.boolean()
.optional()
.describe('This will enable anonymous users to sign in to your application.'),
})
export type AuthGeneralSettingsSchema = z.infer<typeof authGeneralSettingsSchema>
const NO_REQUIRED_CHARACTERS = 'NO_REQUIRED_CHARS'
export const authEmailProviderSchema = z
.object({
external_email_enabled: z
.boolean()
.optional()
.describe('This will enable Email based signup and login for your application.'),
mailer_autoconfirm: z
.boolean()
.optional()
.describe(
'Users will need to confirm their email address before signing in for the first time.'
),
mailer_secure_email_change_enabled: z
.boolean()
.optional()
.describe(
'Users will be required to confirm any email change on both the old email address and new email address. If disabled, only the new email is required to confirm.'
),
security_update_password_require_reauthentication: z
.boolean()
.optional()
.describe(
'Users will need to be recently logged in to change their password without requiring reauthentication. (A user is considered recently logged in if the session was created within the last 24 hours.) If disabled, a user can change their password at any time.'
),
password_hibp_enabled: z
.boolean()
.optional()
.describe(
'Rejects the use of known or easy to guess passwords on sign up or password change. Powered by the HaveIBeenPwned.org Pwned Passwords API.'
),
password_min_length: z
.number()
.int()
.min(6, 'Must be greater or equal to 6.')
.describe(
'Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.'
),
password_required_characters: z
.preprocess(
(val) => (val === '' || val === null || val === undefined ? NO_REQUIRED_CHARACTERS : val),
z.enum([
NO_REQUIRED_CHARACTERS,
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789',
'abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789',
'abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};\'\\\\:"|<>?,./`~',
])
)
.optional()
.transform((val) => (val === NO_REQUIRED_CHARACTERS ? '' : val))
.describe('Passwords that do not have at least one of each will be rejected as weak.'),
mailer_otp_exp: z
.number()
.int()
.min(0, 'Must be more than 0')
.max(86400, 'Must be no more than 86400')
.describe('Duration before an email otp / link expires in seconds.'),
mailer_otp_length: z
.number()
.int()
.min(6, 'Must be at least 6')
.max(10, 'Must be no more than 10')
.optional()
.describe('Number of digits in the email OTP'),
})
.describe('Email provider settings.')
export type AuthEmailProviderSchema = z.infer<typeof authEmailProviderSchema>
export const authPhoneProviderSchema = z
.object({
external_phone_enabled: z
.boolean()
.optional()
.describe('This will enable phone based login for your application'),
sms_provider: z
.enum(['twilio', 'messagebird', 'textlocal', 'vonage', 'twilio_verify'])
.optional()
.describe('External provider that will handle sending SMS messages'),
// Twilio
sms_twilio_account_sid: z.string().optional(),
sms_twilio_auth_token: z.string().optional(),
sms_twilio_message_service_sid: z.string().optional(),
sms_twilio_content_sid: z
.string()
.optional()
.describe('Twilio Content SID (Optional, For WhatsApp Only)'),
// Twilio Verify
sms_twilio_verify_account_sid: z.string().optional(),
sms_twilio_verify_auth_token: z.string().optional(),
sms_twilio_verify_message_service_sid: z.string().optional(),
// Messagebird
sms_messagebird_access_key: z.string().optional(),
sms_messagebird_originator: z.string().optional(),
// Textlocal
sms_textlocal_api_key: z.string().optional(),
sms_textlocal_sender: z.string().optional(),
// Vonage
sms_vonage_api_key: z.string().optional(),
sms_vonage_api_secret: z.string().optional(),
sms_vonage_from: z.string().optional(),
// SMS Confirm settings
sms_autoconfirm: z
.boolean()
.optional()
.describe('Users will need to confirm their phone number before signing in.'),
sms_otp_exp: z
.number()
.int()
.optional()
.describe('Duration before an SMS OTP expires in seconds.'),
sms_otp_length: z.number().int().optional().describe('Number of digits in OTP.'),
sms_template: z.string().optional().describe('To format the OTP code use `{{ .Code }}`'),
sms_test_otp: z
.string()
.optional()
.describe(
'Register phone number and OTP combinations for testing as a comma separated list of <phone number>=<otp> pairs. Example: `18005550123=789012`'
),
sms_test_otp_valid_until: z
.string()
.datetime({ message: 'Invalid datetime string.' })
.optional()
.describe(
"Test phone number and OTP combinations won't be active past this date and time (local time zone)."
),
})
.describe('Phone provider settings.')
export type AuthPhoneProviderSchema = z.infer<typeof authPhoneProviderSchema>
export const authGoogleProviderObject = z.object({
external_google_enabled: z.boolean().optional().describe('Enable Sign in with Google'),
external_google_client_id: z
.string()
.regex(
/^([a-z0-9-]+\.[a-z0-9-]+(\.[a-z0-9-]+)*(,[a-z0-9-]+\.[a-z0-9-]+(\.[a-z0-9-]+)*)*)$/i,
'Google Client IDs should be a comma-separated list of domain-like strings.'
)
.optional(),
external_google_secret: z
.string()
.regex(
/^[a-z0-9.\/_-]*$/i,
'Google OAuth Client Secrets usually contain letters, numbers, dots, dashes, and underscores.'
)
.optional(),
external_google_skip_nonce_check: z
.boolean()
.optional()
.describe('Allows ID tokens with any nonce to be accepted, which is less secure.'),
})
export const authGoogleProviderSchema = authGoogleProviderObject
.superRefine((data, ctx) => {
if (data.external_google_enabled && !data.external_google_client_id) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['external_google_client_id'],
message: 'At least one Client ID is required when Google sign-in is enabled.',
})
}
if (data.external_google_client_id && data.external_google_client_id.includes(' ')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['external_google_client_id'],
message: 'Client IDs should not contain spaces.',
})
}
})
.describe('Google provider settings.')
export type AuthGoogleProviderSchema = z.infer<typeof authGoogleProviderSchema>
export const authConfigUpdateSchema = authGeneralSettingsSchema
.merge(authEmailProviderSchema)
.merge(authPhoneProviderSchema)
.merge(authGoogleProviderObject)
export type AuthConfigUpdateSchema = z.infer<typeof authConfigUpdateSchema>
// A version used for partial updates (all fields optional)
export const authConfigUpdatePayloadSchema = authConfigUpdateSchema.partial()
@@ -0,0 +1,29 @@
import z from 'zod'
export const secretsSchema = z
.object({
secrets: z.array(
z.object({
name: z
.string()
.min(1, 'Secret name is required.')
.regex(
/^[a-zA-Z_][a-zA-Z0-9_]*$/,
'Must contain letters, numbers, and underscores, starting with a letter or underscore.'
),
value: z.string().min(1, 'Secret value is required.'),
})
),
})
.describe('Secrets schema for managing environment variables.')
export type SecretsSchema = z.infer<typeof secretsSchema>
export const deleteSecretsSchema = z
.object({
secretNames: z
.array(z.string().min(1, 'Secret name cannot be empty.'))
.min(1, 'At least one secret name is required.'),
})
.describe('Schema for deleting secrets by name.')
export type DeleteSecretsSchema = z.infer<typeof deleteSecretsSchema>
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+3156 -31
View File
File diff suppressed because it is too large. Load diff
+33 -2
View File
@@ -9,15 +9,46 @@
"lint": "eslint"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@monaco-editor/react": "^4.7.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.93.3",
"@tanstack/react-query": "^5.90.20",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.13.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"common-tags": "^1.8.2",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"openai": "^6.17.0",
"openapi-fetch": "^0.15.0",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"react-hook-form": "^7.71.1",
"react-markdown": "^10.1.0",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"vaul": "^1.1.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/node": "^20.19.31",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
@@ -139,39 +139,10 @@ Deno.serve(async (req: Request): Promise<Response> => {
}
// =========================================================================
// 4. CREATE PROVISIONAL PROJECT RECORD
// 4. CALL MANAGEMENT API
// =========================================================================
const { data: provisionalProject, error: insertError } = await supabaseWithAuth
.from('projects')
.insert({
project_name,
organization_id,
region,
purpose,
description,
creator_id: user.id,
creator_email: user.email!,
status: 'provisioning',
// Temporary values, will be updated after API call
project_ref: 'pending',
anon_key: 'pending',
service_role_key_encrypted: 'pending',
})
.select('id')
.single()
if (insertError || !provisionalProject) {
console.error('Error creating provisional project:', insertError)
return jsonError('Failed to create project record', 500)
}
const projectId = provisionalProject.id
try {
// =========================================================================
// 5. CALL MANAGEMENT API
// =========================================================================
const managementAccessToken = Deno.env.get('MANAGEMENT_ACCESS_TOKEN')
if (!managementAccessToken) {
@@ -237,6 +208,16 @@ Deno.serve(async (req: Request): Promise<Response> => {
console.log(`Project created: ${newProject.id}`)
const projectRef = newProject.id
// Extract organization_id from Management API response
const actualOrgId = newProject.organization_id || organization_id
console.log(`Organization ID from Management API: ${actualOrgId}`)
console.log(`Organization ID from request: ${organization_id}`)
console.log(`Organization ID from API response: ${newProject.organization_id}`)
// Validate that we have a valid organization_id
if (!actualOrgId || actualOrgId === 'undefined') {
throw new Error(`Invalid organization_id: ${actualOrgId}`)
}
// Wait for project to be ready and fetch API keys
// The project needs time to provision before keys are available
@@ -291,24 +272,34 @@ Deno.serve(async (req: Request): Promise<Response> => {
}
// =========================================================================
// 7. UPDATE PROJECT RECORD WITH CREDENTIALS
// 7. CREATE PROJECT RECORD
// =========================================================================
const { error: updateError } = await supabaseWithAuth
const { data: createdProject, error: insertError } = await supabaseWithAuth
.from('projects')
.update({
.insert({
project_name,
organization_id: actualOrgId,
region,
purpose,
description,
creator_id: user.id,
creator_email: user.email!,
project_ref: projectRef,
anon_key: anonKey,
service_role_key_encrypted: encryptedKey,
status: 'active',
management_api_response: newProject,
})
.eq('id', projectId)
.select('id')
.single()
if (updateError) {
throw new Error(`Failed to update project: ${updateError.message}`)
if (insertError || !createdProject) {
throw new Error(`Failed to create project record: ${insertError?.message}`)
}
const projectId = createdProject.id
// =========================================================================
// 8. CREATE AUDIT LOG
// =========================================================================
@@ -318,7 +309,7 @@ Deno.serve(async (req: Request): Promise<Response> => {
p_action: 'create',
p_actor_id: user.id,
p_actor_email: user.email!,
p_organization_id: organization_id,
p_organization_id: actualOrgId,
p_metadata: {
project_name,
region,
@@ -340,17 +331,7 @@ Deno.serve(async (req: Request): Promise<Response> => {
return jsonResponse(response, 201)
} catch (error) {
// If Management API call fails, update project status to 'failed'
await supabaseWithAuth
.from('projects')
.update({
status: 'failed',
management_api_response: {
error: error instanceof Error ? error.message : String(error),
},
})
.eq('id', projectId)
// If project creation fails, log the error and return failure response
console.error('Error creating project:', error)
return jsonError(
@@ -0,0 +1,251 @@
/**
* SupaBuilder - Delete Project Edge Function
*
* This edge function handles deletion of Supabase projects via the Management API.
* Only admins can delete projects.
*
* Flow:
* 1. Verify JWT from Authorization header
* 2. Parse & validate request (project_id)
* 3. Verify user is admin for the project's organization
* 4. Fetch project details (to get project_ref)
* 5. Call Management API to delete the project
* 6. Update project record in database (soft delete)
* 7. Create audit log entry
* 8. Return success response
*/
import { createClient } from 'npm:@supabase/supabase-js@^2.93.3'
import { corsHeaders, handleCors } from '../_shared/cors.ts'
interface DeleteProjectRequest {
project_id: string
}
interface DeleteProjectResponse {
success: boolean
message: string
project_id?: string
}
interface ErrorResponse {
success: false
error: string
details?: unknown
}
Deno.serve(async (req: Request): Promise<Response> => {
// Handle CORS preflight
const corsResponse = handleCors(req)
if (corsResponse) return corsResponse
try {
// =========================================================================
// 1. VERIFY JWT AND GET USER INFO
// =========================================================================
const authHeader = req.headers.get('Authorization')
if (!authHeader) {
return jsonError('Missing Authorization header', 401)
}
const jwt = authHeader.replace('Bearer ', '')
// Create Supabase client for database operations
const supabaseUrl = Deno.env.get('SUPABASE_URL')!
const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY')!
const supabaseWithAuth = createClient(supabaseUrl, supabaseAnonKey, {
global: {
headers: {
Authorization: authHeader,
},
},
auth: {
persistSession: false,
autoRefreshToken: false,
},
})
// Verify JWT and get user
const {
data: { user },
error: authError,
} = await supabaseWithAuth.auth.getUser(jwt)
if (authError || !user) {
console.error('Authentication failed:', authError)
return jsonError('Authentication failed', 401)
}
console.log(`Delete project request from user: ${user.email}`)
// =========================================================================
// 2. PARSE AND VALIDATE REQUEST
// =========================================================================
let requestData: DeleteProjectRequest
try {
requestData = await req.json()
} catch {
return jsonError('Invalid JSON in request body', 400)
}
const { project_id } = requestData
if (!project_id) {
return jsonError('project_id is required', 400)
}
console.log(`Deleting project: ${project_id}`)
// =========================================================================
// 3. FETCH PROJECT DETAILS
// =========================================================================
const { data: project, error: projectError } = await supabaseWithAuth
.from('projects')
.select('id, project_ref, project_name, organization_id, status')
.eq('id', project_id)
.is('deleted_at', null)
.single()
if (projectError || !project) {
console.error('Project not found:', projectError)
return jsonError('Project not found or already deleted', 404)
}
// =========================================================================
// 4. VERIFY USER IS ADMIN
// =========================================================================
const { data: userRole, error: roleError } = await supabaseWithAuth
.from('user_roles')
.select('role')
.eq('user_id', user.id)
.eq('organization_id', project.organization_id)
.single()
if (roleError || !userRole || userRole.role !== 'admin') {
console.error('Permission denied: User is not admin')
return jsonError('Only admins can delete projects', 403)
}
console.log(`User ${user.email} is admin for organization ${project.organization_id}`)
// =========================================================================
// 5. CALL MANAGEMENT API TO DELETE PROJECT
// =========================================================================
const managementAccessToken = Deno.env.get('MANAGEMENT_ACCESS_TOKEN')
if (!managementAccessToken) {
throw new Error('MANAGEMENT_ACCESS_TOKEN not configured')
}
console.log(`Calling Management API to delete project: ${project.project_ref}`)
const deleteResponse = await fetch(
`https://api.supabase.com/v1/projects/${project.project_ref}`,
{
method: 'DELETE',
headers: {
'Authorization': `Bearer ${managementAccessToken}`,
'Content-Type': 'application/json',
},
}
)
// Management API returns 200 or 204 on success
if (!deleteResponse.ok && deleteResponse.status !== 204) {
const errorText = await deleteResponse.text()
console.error(`Management API error (${deleteResponse.status}):`, errorText)
// If project is already deleted on Supabase side, we'll still mark it deleted locally
if (deleteResponse.status === 404) {
console.log('Project not found in Management API, proceeding with local deletion')
} else {
throw new Error(`Management API error (${deleteResponse.status}): ${errorText}`)
}
}
console.log('Project successfully deleted from Management API')
// =========================================================================
// 6. UPDATE PROJECT RECORD (SOFT DELETE)
// =========================================================================
const { error: updateError } = await supabaseWithAuth
.from('projects')
.update({
status: 'deleted',
deleted_at: new Date().toISOString(),
})
.eq('id', project_id)
if (updateError) {
console.error('Failed to update project record:', updateError)
throw new Error(`Failed to update project record: ${updateError.message}`)
}
console.log('Project record updated with deleted status')
// =========================================================================
// 7. CREATE AUDIT LOG
// =========================================================================
await supabaseWithAuth.rpc('create_audit_log', {
p_project_id: project_id,
p_action: 'delete',
p_actor_id: user.id,
p_actor_email: user.email!,
p_organization_id: project.organization_id,
p_metadata: {
project_name: project.project_name,
project_ref: project.project_ref,
previous_status: project.status,
},
})
console.log('Audit log created')
// =========================================================================
// 8. RETURN SUCCESS RESPONSE
// =========================================================================
const response: DeleteProjectResponse = {
success: true,
message: 'Project deleted successfully',
project_id: project_id,
}
return jsonResponse(response, 200)
} catch (error) {
console.error('Unexpected error:', error)
return jsonError(
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
500
)
}
})
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
function jsonResponse(data: DeleteProjectResponse, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
function jsonError(error: string, status: number): Response {
const response: ErrorResponse = {
success: false,
error,
}
return new Response(JSON.stringify(response), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
})
}
@@ -0,0 +1,790 @@
-- ============================================================================
-- SupaBuilder Initial Setup
-- Complete database schema for enterprise Supabase project provisioning
-- ============================================================================
-- Enable required extensions
create extension if not exists "pgcrypto";
-- ============================================================================
-- TABLES
-- ============================================================================
-- Projects table: Tracks all child Supabase projects
create table public.projects (
id uuid primary key default gen_random_uuid(),
-- Project identification
project_ref text unique not null,
project_name text not null,
organization_id text not null,
-- Credentials (encrypted)
anon_key text not null,
service_role_key_encrypted text not null,
-- Project configuration
region text not null,
status text not null default 'provisioning' check (status in ('provisioning', 'active', 'paused', 'failed', 'deleted')),
-- Metadata
purpose text,
description text,
-- Ownership and tracking
creator_id uuid not null,
creator_email text not null,
-- Timestamps
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
deleted_at timestamptz,
-- Management API metadata
management_api_response jsonb,
constraint projects_org_name_unique unique (organization_id, project_name, deleted_at)
);
create index idx_projects_organization on public.projects(organization_id) where deleted_at is null;
create index idx_projects_creator on public.projects(creator_id) where deleted_at is null;
create index idx_projects_status on public.projects(status) where deleted_at is null;
create index idx_projects_created_at on public.projects(created_at desc);
comment on table public.projects is 'Child Supabase projects created through SupaBuilder';
-- User roles table: Manages admin vs builder role assignments
create table public.user_roles (
id uuid primary key default gen_random_uuid(),
user_id uuid not null,
organization_id text not null,
role text not null check (role in ('admin', 'builder')),
assigned_by uuid,
assigned_at timestamptz not null default now(),
constraint user_roles_unique unique (user_id, organization_id)
);
create index idx_user_roles_user on public.user_roles(user_id);
create index idx_user_roles_org on public.user_roles(organization_id);
comment on table public.user_roles is 'Role assignments for users within organizations';
-- Project audit logs: Immutable audit trail
create table public.project_audit_logs (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
action text not null check (action in ('create', 'pause', 'resume', 'delete', 'update')),
actor_id uuid not null,
actor_email text not null,
organization_id text not null,
metadata jsonb,
created_at timestamptz not null default now()
);
create index idx_audit_project on public.project_audit_logs(project_id, created_at desc);
create index idx_audit_actor on public.project_audit_logs(actor_id, created_at desc);
create index idx_audit_org on public.project_audit_logs(organization_id, created_at desc);
create index idx_audit_action on public.project_audit_logs(action);
comment on table public.project_audit_logs is 'Immutable audit trail for all project operations';
-- Rate limits: DB-based rate limiting
create table public.rate_limits (
id uuid primary key default gen_random_uuid(),
user_id uuid not null,
organization_id text not null,
action text not null,
created_at timestamptz not null default now(),
constraint rate_limits_idx unique (user_id, organization_id, action, created_at)
);
create index idx_rate_limits_user_action on public.rate_limits(user_id, action, created_at desc);
comment on table public.rate_limits is 'Rate limiting tracking for user actions';
-- ============================================================================
-- ENCRYPTION FUNCTIONS
-- ============================================================================
create or replace function public.encrypt_service_role_key(
p_service_role_key text,
p_encryption_key text
)
returns text
language plpgsql
security definer
as $$
begin
return encode(
pgp_sym_encrypt(
p_service_role_key,
p_encryption_key,
'cipher-algo=aes256'
),
'base64'
);
end;
$$;
create or replace function public.decrypt_service_role_key(
p_encrypted_key text,
p_encryption_key text
)
returns text
language plpgsql
security definer
as $$
begin
return pgp_sym_decrypt(
decode(p_encrypted_key, 'base64'),
p_encryption_key,
'cipher-algo=aes256'
);
end;
$$;
comment on function public.encrypt_service_role_key is 'Encrypt service_role_key using AES-256';
comment on function public.decrypt_service_role_key is 'Decrypt service_role_key (admin only)';
-- ============================================================================
-- TRIGGERS
-- ============================================================================
create or replace function public.handle_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
create trigger set_updated_at
before update on public.projects
for each row
execute function public.handle_updated_at();
-- ============================================================================
-- HELPER FUNCTIONS
-- ============================================================================
-- Get user's role in an organization
create or replace function public.get_user_role(
p_user_id uuid,
p_organization_id text
)
returns text
language plpgsql
security definer
stable
as $$
declare
v_role text;
begin
select role into v_role
from public.user_roles
where user_id = p_user_id
and organization_id = p_organization_id;
return coalesce(v_role, 'builder');
end;
$$;
comment on function public.get_user_role is 'Get user role within an organization';
-- Check if user is admin
create or replace function public.is_admin(
p_user_id uuid,
p_organization_id text
)
returns boolean
language plpgsql
security definer
stable
as $$
begin
return exists (
select 1
from public.user_roles
where user_id = p_user_id
and organization_id = p_organization_id
and role = 'admin'
);
end;
$$;
comment on function public.is_admin is 'Check if user has admin role in organization';
-- Rate limiting check
create or replace function public.check_rate_limit(
p_user_id uuid,
p_organization_id text,
p_action text,
p_max_requests integer default 5,
p_window_minutes integer default 60
)
returns boolean
language plpgsql
security definer
as $$
declare
v_count integer;
v_window_start timestamptz;
begin
v_window_start := now() - (p_window_minutes || ' minutes')::interval;
select count(*)
into v_count
from public.rate_limits
where user_id = p_user_id
and organization_id = p_organization_id
and action = p_action
and created_at > v_window_start;
return v_count < p_max_requests;
end;
$$;
comment on function public.check_rate_limit is 'Check if user is within rate limit for an action';
-- Record rate limit event
create or replace function public.record_rate_limit(
p_user_id uuid,
p_organization_id text,
p_action text
)
returns uuid
language plpgsql
security definer
as $$
declare
v_id uuid;
begin
insert into public.rate_limits (user_id, organization_id, action)
values (p_user_id, p_organization_id, p_action)
returning id into v_id;
return v_id;
end;
$$;
comment on function public.record_rate_limit is 'Record a rate limit event';
-- Create audit log entry
create or replace function public.create_audit_log(
p_project_id uuid,
p_action text,
p_actor_id uuid,
p_actor_email text,
p_organization_id text,
p_metadata jsonb default null
)
returns uuid
language plpgsql
security definer
as $$
declare
v_id uuid;
begin
insert into public.project_audit_logs (
project_id,
action,
actor_id,
actor_email,
organization_id,
metadata
)
values (
p_project_id,
p_action,
p_actor_id,
p_actor_email,
p_organization_id,
p_metadata
)
returning id into v_id;
return v_id;
end;
$$;
comment on function public.create_audit_log is 'Create an audit log entry';
-- Update project status with audit logging
create or replace function public.update_project_status(
p_project_id uuid,
p_new_status text,
p_actor_id uuid,
p_actor_email text
)
returns boolean
language plpgsql
security definer
as $$
declare
v_old_status text;
v_organization_id text;
begin
if p_new_status not in ('provisioning', 'active', 'paused', 'failed', 'deleted') then
raise exception 'Invalid status: %', p_new_status;
end if;
select status, organization_id
into v_old_status, v_organization_id
from public.projects
where id = p_project_id;
if not found then
raise exception 'Project not found: %', p_project_id;
end if;
update public.projects
set status = p_new_status
where id = p_project_id;
perform public.create_audit_log(
p_project_id,
'update',
p_actor_id,
p_actor_email,
v_organization_id,
jsonb_build_object(
'field', 'status',
'old_value', v_old_status,
'new_value', p_new_status
)
);
return true;
end;
$$;
comment on function public.update_project_status is 'Update project status with audit logging';
-- Soft delete project
create or replace function public.soft_delete_project(
p_project_id uuid,
p_actor_id uuid,
p_actor_email text
)
returns boolean
language plpgsql
security definer
as $$
declare
v_organization_id text;
begin
select organization_id
into v_organization_id
from public.projects
where id = p_project_id;
if not found then
raise exception 'Project not found: %', p_project_id;
end if;
update public.projects
set
deleted_at = now(),
status = 'deleted'
where id = p_project_id
and deleted_at is null;
perform public.create_audit_log(
p_project_id,
'delete',
p_actor_id,
p_actor_email,
v_organization_id,
jsonb_build_object('soft_delete', true)
);
return true;
end;
$$;
comment on function public.soft_delete_project is 'Soft delete a project with audit logging';
-- Cleanup old rate limits
create or replace function public.cleanup_old_rate_limits(
p_retention_hours integer default 168
)
returns integer
language plpgsql
security definer
as $$
declare
v_deleted integer;
begin
delete from public.rate_limits
where created_at < now() - (p_retention_hours || ' hours')::interval;
get diagnostics v_deleted = row_count;
return v_deleted;
end;
$$;
comment on function public.cleanup_old_rate_limits is 'Clean up old rate limit records (7+ days)';
-- Get user projects count
create or replace function public.get_user_projects_count(
p_user_id uuid,
p_organization_id text
)
returns integer
language plpgsql
security definer
stable
as $$
declare
v_count integer;
begin
select count(*)
into v_count
from public.projects
where creator_id = p_user_id
and organization_id = p_organization_id
and deleted_at is null
and status in ('active', 'provisioning', 'paused');
return v_count;
end;
$$;
comment on function public.get_user_projects_count is 'Get count of active projects for a user';
-- Get organization projects count
create or replace function public.get_org_projects_count(
p_organization_id text
)
returns integer
language plpgsql
security definer
stable
as $$
declare
v_count integer;
begin
select count(*)
into v_count
from public.projects
where organization_id = p_organization_id
and deleted_at is null
and status in ('active', 'provisioning', 'paused');
return v_count;
end;
$$;
comment on function public.get_org_projects_count is 'Get count of all projects in an organization';
-- Assign user role (bypasses RLS)
create or replace function public.assign_user_role(
p_user_id uuid,
p_organization_id text,
p_role text
)
returns boolean
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.user_roles (user_id, organization_id, role)
values (p_user_id, p_organization_id, p_role)
on conflict (user_id, organization_id) do nothing;
return true;
end;
$$;
comment on function public.assign_user_role is 'Assign role to user (bypasses RLS)';
-- Auto-assign first admin
create or replace function public.auto_assign_first_admin()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
declare
v_org_id text;
v_user_count integer;
begin
v_org_id := coalesce(
new.raw_user_meta_data->>'organization_id',
new.raw_user_meta_data->>'org_id',
new.raw_user_meta_data->>'hd',
new.raw_app_meta_data->>'organization_id',
'default_org'
);
select count(*)
into v_user_count
from public.user_roles
where organization_id = v_org_id;
if v_user_count = 0 then
perform public.assign_user_role(new.id, v_org_id, 'admin');
raise notice 'Auto-assigned admin role to user % in organization %', new.email, v_org_id;
else
perform public.assign_user_role(new.id, v_org_id, 'builder');
raise notice 'Auto-assigned builder role to user % in organization %', new.email, v_org_id;
end if;
return new;
end;
$$;
comment on function public.auto_assign_first_admin is 'Automatically assign admin role to first user per organization, builder to others';
create trigger on_auth_user_created
after insert on auth.users
for each row
execute function public.auto_assign_first_admin();
-- Promote user to admin
create or replace function public.promote_to_admin(
p_user_email text,
p_organization_id text
)
returns boolean
language plpgsql
security definer
set search_path = public
as $$
declare
v_user_id uuid;
v_caller_role text;
begin
select role into v_caller_role
from public.user_roles
where user_id = auth.uid()
and organization_id = p_organization_id;
if v_caller_role != 'admin' then
raise exception 'Only admins can promote users';
end if;
select id into v_user_id
from auth.users
where email = p_user_email;
if v_user_id is null then
raise exception 'User not found: %', p_user_email;
end if;
update public.user_roles
set role = 'admin',
assigned_by = auth.uid(),
assigned_at = now()
where user_id = v_user_id
and organization_id = p_organization_id;
return true;
end;
$$;
comment on function public.promote_to_admin is 'Allow admins to promote other users to admin role';
-- ============================================================================
-- ROW LEVEL SECURITY
-- ============================================================================
alter table public.projects enable row level security;
alter table public.user_roles enable row level security;
alter table public.project_audit_logs enable row level security;
alter table public.rate_limits enable row level security;
-- Projects policies
create policy "Admins can view all organization projects"
on public.projects
for select
to authenticated
using (
deleted_at is null
and (
creator_id = auth.uid()
or
exists (
select 1
from public.user_roles
where user_roles.user_id = auth.uid()
and user_roles.organization_id = projects.organization_id
and user_roles.role = 'admin'
)
)
);
create policy "Builders can view their own projects"
on public.projects
for select
to authenticated
using (
creator_id = auth.uid()
and deleted_at is null
);
create policy "Authenticated users can create projects"
on public.projects
for insert
to authenticated
with check (creator_id = auth.uid());
create policy "Admins can update organization projects"
on public.projects
for update
to authenticated
using (
creator_id = auth.uid()
or
exists (
select 1
from public.user_roles
where user_roles.user_id = auth.uid()
and user_roles.organization_id = projects.organization_id
and user_roles.role = 'admin'
)
)
with check (
creator_id = auth.uid()
or
exists (
select 1
from public.user_roles
where user_roles.user_id = auth.uid()
and user_roles.organization_id = projects.organization_id
and user_roles.role = 'admin'
)
);
create policy "Builders can update their own projects"
on public.projects
for update
to authenticated
using (creator_id = auth.uid())
with check (creator_id = auth.uid());
-- User roles policies (no recursion)
create policy "Authenticated users can view all roles"
on public.user_roles
for select
to authenticated
using (true);
create policy "No direct role inserts"
on public.user_roles
for insert
to authenticated
with check (false);
create policy "No direct role updates"
on public.user_roles
for update
to authenticated
using (false);
create policy "No direct role deletes"
on public.user_roles
for delete
to authenticated
using (false);
-- Audit logs policies
create policy "Admins can view organization audit logs"
on public.project_audit_logs
for select
to authenticated
using (
actor_id = auth.uid()
or
exists (
select 1
from public.projects
where projects.id = project_audit_logs.project_id
and projects.creator_id = auth.uid()
)
or
exists (
select 1
from public.user_roles
where user_roles.user_id = auth.uid()
and user_roles.organization_id = project_audit_logs.organization_id
and user_roles.role = 'admin'
)
);
create policy "Builders can view their project audit logs"
on public.project_audit_logs
for select
to authenticated
using (
exists (
select 1
from public.projects
where projects.id = project_audit_logs.project_id
and projects.creator_id = auth.uid()
)
);
create policy "Users can create audit logs"
on public.project_audit_logs
for insert
to authenticated
with check (actor_id = auth.uid());
-- Rate limits policies
create policy "Users can view their own rate limits"
on public.rate_limits
for select
to authenticated
using (user_id = auth.uid());
create policy "Users can create rate limit records"
on public.rate_limits
for insert
to authenticated
with check (user_id = auth.uid());
create policy "Admins can view organization rate limits"
on public.rate_limits
for select
to authenticated
using (
user_id = auth.uid()
or
exists (
select 1
from public.user_roles
where user_roles.user_id = auth.uid()
and user_roles.organization_id = rate_limits.organization_id
and user_roles.role = 'admin'
)
);
-- ============================================================================
-- GRANTS
-- ============================================================================
grant usage on schema public to authenticated;
grant usage on schema public to anon;
grant select, insert, update on public.projects to authenticated;
grant select on public.user_roles to authenticated;
grant select, insert on public.project_audit_logs to authenticated;
grant select, insert on public.rate_limits to authenticated;
grant execute on function public.get_user_role to authenticated;
grant execute on function public.is_admin to authenticated;
grant execute on function public.check_rate_limit to authenticated;
grant execute on function public.record_rate_limit to authenticated;
grant execute on function public.create_audit_log to authenticated;
grant execute on function public.update_project_status to authenticated;
grant execute on function public.soft_delete_project to authenticated;
grant execute on function public.cleanup_old_rate_limits to authenticated;
grant execute on function public.get_user_projects_count to authenticated;
grant execute on function public.get_org_projects_count to authenticated;
grant execute on function public.assign_user_role to authenticated;
grant execute on function public.promote_to_admin to authenticated;
@@ -1,277 +0,0 @@
-- SupaBuilder Initial Schema
-- Core tables for enterprise project provisioning platform
-- Enable required extensions
create extension if not exists "pgcrypto";
-- ============================================================================
-- PROJECTS TABLE
-- Tracks all child Supabase projects created through SupaBuilder
-- ============================================================================
create table public.projects (
id uuid primary key default gen_random_uuid(),
-- Project identification
project_ref text unique not null, -- Supabase project reference (e.g., "abcdefghijklmnop")
project_name text not null,
organization_id text not null, -- Enterprise org identifier
-- Credentials (encrypted)
anon_key text not null,
service_role_key_encrypted text not null, -- Encrypted with pgcrypto
-- Project configuration
region text not null, -- AWS region (e.g., "us-east-1")
status text not null default 'provisioning' check (status in ('provisioning', 'active', 'paused', 'failed', 'deleted')),
-- Metadata
purpose text, -- Why this project was created
description text,
-- Ownership and tracking
creator_id uuid not null, -- User who created the project (references auth.users)
creator_email text not null,
-- Timestamps
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
deleted_at timestamptz, -- Soft delete
-- Management API metadata
management_api_response jsonb, -- Store full API response for debugging
-- Indexes for common queries
constraint projects_org_name_unique unique (organization_id, project_name, deleted_at)
);
-- Indexes for performance
create index idx_projects_organization on public.projects(organization_id) where deleted_at is null;
create index idx_projects_creator on public.projects(creator_id) where deleted_at is null;
create index idx_projects_status on public.projects(status) where deleted_at is null;
create index idx_projects_created_at on public.projects(created_at desc);
-- Comment
comment on table public.projects is 'Child Supabase projects created through SupaBuilder';
-- ============================================================================
-- USER ROLES TABLE
-- Manages admin vs builder role assignments per organization
-- ============================================================================
create table public.user_roles (
id uuid primary key default gen_random_uuid(),
user_id uuid not null, -- references auth.users(id)
organization_id text not null,
role text not null check (role in ('admin', 'builder')),
-- Metadata
assigned_by uuid, -- Admin who assigned this role
assigned_at timestamptz not null default now(),
-- Ensure one role per user per org
constraint user_roles_unique unique (user_id, organization_id)
);
-- Indexes
create index idx_user_roles_user on public.user_roles(user_id);
create index idx_user_roles_org on public.user_roles(organization_id);
comment on table public.user_roles is 'Role assignments for users within organizations';
-- ============================================================================
-- PROJECT AUDIT LOGS TABLE
-- Immutable audit trail of all project operations
-- ============================================================================
create table public.project_audit_logs (
id uuid primary key default gen_random_uuid(),
project_id uuid not null references public.projects(id) on delete cascade,
-- Action details
action text not null check (action in ('create', 'pause', 'resume', 'delete', 'update')),
actor_id uuid not null, -- User who performed the action
actor_email text not null,
-- Context
organization_id text not null,
metadata jsonb, -- Additional context (e.g., what was changed)
-- Timestamp (immutable)
created_at timestamptz not null default now()
);
-- Indexes for audit queries
create index idx_audit_project on public.project_audit_logs(project_id, created_at desc);
create index idx_audit_actor on public.project_audit_logs(actor_id, created_at desc);
create index idx_audit_org on public.project_audit_logs(organization_id, created_at desc);
create index idx_audit_action on public.project_audit_logs(action);
comment on table public.project_audit_logs is 'Immutable audit trail for all project operations';
-- ============================================================================
-- RATE LIMITS TABLE
-- Simple DB-based rate limiting (5 projects/hour per user)
-- ============================================================================
create table public.rate_limits (
id uuid primary key default gen_random_uuid(),
user_id uuid not null,
organization_id text not null,
action text not null, -- e.g., 'create_project'
created_at timestamptz not null default now(),
-- Index for quick lookups
constraint rate_limits_idx unique (user_id, organization_id, action, created_at)
);
-- Index for time-based queries
create index idx_rate_limits_user_action on public.rate_limits(user_id, action, created_at desc);
comment on table public.rate_limits is 'Rate limiting tracking for user actions';
-- ============================================================================
-- ENCRYPTION FUNCTIONS
-- Encrypt/decrypt service_role_key using pgcrypto
-- ============================================================================
-- Encryption function
-- Uses AES-256 encryption with a key from environment/secrets
create or replace function public.encrypt_service_role_key(
p_service_role_key text,
p_encryption_key text
)
returns text
language plpgsql
security definer
as $$
begin
return encode(
pgp_sym_encrypt(
p_service_role_key,
p_encryption_key,
'cipher-algo=aes256'
),
'base64'
);
end;
$$;
-- Decryption function (admin only)
create or replace function public.decrypt_service_role_key(
p_encrypted_key text,
p_encryption_key text
)
returns text
language plpgsql
security definer
as $$
begin
return pgp_sym_decrypt(
decode(p_encrypted_key, 'base64'),
p_encryption_key,
'cipher-algo=aes256'
);
end;
$$;
comment on function public.encrypt_service_role_key is 'Encrypt service_role_key using AES-256';
comment on function public.decrypt_service_role_key is 'Decrypt service_role_key (admin only)';
-- ============================================================================
-- TRIGGERS
-- Auto-update timestamps
-- ============================================================================
-- Function to update updated_at timestamp
create or replace function public.handle_updated_at()
returns trigger
language plpgsql
as $$
begin
new.updated_at = now();
return new;
end;
$$;
-- Apply trigger to projects table
create trigger set_updated_at
before update on public.projects
for each row
execute function public.handle_updated_at();
-- ============================================================================
-- HELPER FUNCTIONS
-- ============================================================================
-- Function to get user's role in an organization
create or replace function public.get_user_role(
p_user_id uuid,
p_organization_id text
)
returns text
language plpgsql
security definer
stable
as $$
declare
v_role text;
begin
select role into v_role
from public.user_roles
where user_id = p_user_id
and organization_id = p_organization_id;
return coalesce(v_role, 'builder'); -- Default to builder if no role assigned
end;
$$;
comment on function public.get_user_role is 'Get user role within an organization';
-- Function to check if user is admin
create or replace function public.is_admin(
p_user_id uuid,
p_organization_id text
)
returns boolean
language plpgsql
security definer
stable
as $$
begin
return exists (
select 1
from public.user_roles
where user_id = p_user_id
and organization_id = p_organization_id
and role = 'admin'
);
end;
$$;
comment on function public.is_admin is 'Check if user has admin role in organization';
-- ============================================================================
-- GRANTS
-- Enable RLS and grant basic permissions
-- ============================================================================
-- Enable RLS on all tables
alter table public.projects enable row level security;
alter table public.user_roles enable row level security;
alter table public.project_audit_logs enable row level security;
alter table public.rate_limits enable row level security;
-- Grant usage on schema
grant usage on schema public to authenticated;
grant usage on schema public to anon;
-- Grant permissions on tables (RLS policies will control actual access)
grant select, insert, update on public.projects to authenticated;
grant select on public.user_roles to authenticated;
grant select, insert on public.project_audit_logs to authenticated;
grant select, insert on public.rate_limits to authenticated;
-- Grant execute on functions
grant execute on function public.get_user_role to authenticated;
grant execute on function public.is_admin to authenticated;
@@ -1,233 +0,0 @@
-- SupaBuilder RLS Policies
-- Row Level Security policies for admin vs builder access control
-- ============================================================================
-- PROJECTS TABLE POLICIES
-- Admins see all org projects, builders see only their own
-- ============================================================================
-- Policy: Admins can see all projects in their organization
create policy "Admins can view all organization projects"
on public.projects
for select
to authenticated
using (
exists (
select 1
from public.user_roles
where user_roles.user_id = auth.uid()
and user_roles.organization_id = projects.organization_id
and user_roles.role = 'admin'
)
and deleted_at is null
);
-- Policy: Builders can only see projects they created
create policy "Builders can view their own projects"
on public.projects
for select
to authenticated
using (
creator_id = auth.uid()
and deleted_at is null
);
-- Policy: All authenticated users can create projects (rate limited by function)
create policy "Authenticated users can create projects"
on public.projects
for insert
to authenticated
with check (
creator_id = auth.uid()
);
-- Policy: Admins can update any project in their organization
create policy "Admins can update organization projects"
on public.projects
for update
to authenticated
using (
exists (
select 1
from public.user_roles
where user_roles.user_id = auth.uid()
and user_roles.organization_id = projects.organization_id
and user_roles.role = 'admin'
)
)
with check (
exists (
select 1
from public.user_roles
where user_roles.user_id = auth.uid()
and user_roles.organization_id = projects.organization_id
and user_roles.role = 'admin'
)
);
-- Policy: Builders can update their own projects (limited fields)
create policy "Builders can update their own projects"
on public.projects
for update
to authenticated
using (creator_id = auth.uid())
with check (creator_id = auth.uid());
-- ============================================================================
-- USER ROLES TABLE POLICIES
-- Only admins can modify roles, everyone can view their own role
-- ============================================================================
-- Policy: Users can view their own roles
create policy "Users can view their own roles"
on public.user_roles
for select
to authenticated
using (user_id = auth.uid());
-- Policy: Admins can view all roles in their organization
create policy "Admins can view organization roles"
on public.user_roles
for select
to authenticated
using (
exists (
select 1
from public.user_roles as ur
where ur.user_id = auth.uid()
and ur.organization_id = user_roles.organization_id
and ur.role = 'admin'
)
);
-- Policy: Admins can assign roles in their organization
create policy "Admins can assign roles"
on public.user_roles
for insert
to authenticated
with check (
exists (
select 1
from public.user_roles as ur
where ur.user_id = auth.uid()
and ur.organization_id = user_roles.organization_id
and ur.role = 'admin'
)
);
-- Policy: Admins can update roles in their organization
create policy "Admins can update roles"
on public.user_roles
for update
to authenticated
using (
exists (
select 1
from public.user_roles as ur
where ur.user_id = auth.uid()
and ur.organization_id = user_roles.organization_id
and ur.role = 'admin'
)
);
-- Policy: Admins can delete roles in their organization (except their own)
create policy "Admins can delete roles"
on public.user_roles
for delete
to authenticated
using (
user_id != auth.uid() -- Can't delete own role
and exists (
select 1
from public.user_roles as ur
where ur.user_id = auth.uid()
and ur.organization_id = user_roles.organization_id
and ur.role = 'admin'
)
);
-- ============================================================================
-- PROJECT AUDIT LOGS POLICIES
-- Admins can view all org logs, builders can view their project logs
-- ============================================================================
-- Policy: Admins can view all audit logs in their organization
create policy "Admins can view organization audit logs"
on public.project_audit_logs
for select
to authenticated
using (
exists (
select 1
from public.user_roles
where user_roles.user_id = auth.uid()
and user_roles.organization_id = project_audit_logs.organization_id
and user_roles.role = 'admin'
)
);
-- Policy: Builders can view audit logs for their projects
create policy "Builders can view their project audit logs"
on public.project_audit_logs
for select
to authenticated
using (
exists (
select 1
from public.projects
where projects.id = project_audit_logs.project_id
and projects.creator_id = auth.uid()
)
);
-- Policy: All authenticated users can insert audit logs (for their own actions)
create policy "Users can create audit logs"
on public.project_audit_logs
for insert
to authenticated
with check (actor_id = auth.uid());
-- ============================================================================
-- RATE LIMITS TABLE POLICIES
-- Users can view their own rate limits
-- ============================================================================
-- Policy: Users can view their own rate limits
create policy "Users can view their own rate limits"
on public.rate_limits
for select
to authenticated
using (user_id = auth.uid());
-- Policy: Users can insert their own rate limit records
create policy "Users can create rate limit records"
on public.rate_limits
for insert
to authenticated
with check (user_id = auth.uid());
-- Policy: Admins can view all rate limits in their organization
create policy "Admins can view organization rate limits"
on public.rate_limits
for select
to authenticated
using (
exists (
select 1
from public.user_roles
where user_roles.user_id = auth.uid()
and user_roles.organization_id = rate_limits.organization_id
and user_roles.role = 'admin'
)
);
-- ============================================================================
-- SECURITY NOTES
-- ============================================================================
-- 1. Service role keys are encrypted in the database
-- 2. Decryption function should only be called server-side with proper auth
-- 3. Builders cannot see other builders' projects
-- 4. Admins have full visibility within their organization
-- 5. Audit logs are immutable (no update/delete policies)
-- 6. Rate limits prevent abuse even before RLS checks
@@ -1,321 +0,0 @@
-- SupaBuilder Helper Functions
-- Rate limiting and utility functions
-- ============================================================================
-- RATE LIMITING FUNCTION
-- Checks if user has exceeded rate limit (5 projects/hour)
-- ============================================================================
create or replace function public.check_rate_limit(
p_user_id uuid,
p_organization_id text,
p_action text,
p_max_requests integer default 5,
p_window_minutes integer default 60
)
returns boolean
language plpgsql
security definer
as $$
declare
v_count integer;
v_window_start timestamptz;
begin
-- Calculate time window start
v_window_start := now() - (p_window_minutes || ' minutes')::interval;
-- Count requests in the time window
select count(*)
into v_count
from public.rate_limits
where user_id = p_user_id
and organization_id = p_organization_id
and action = p_action
and created_at > v_window_start;
-- Return true if under limit, false if exceeded
return v_count < p_max_requests;
end;
$$;
comment on function public.check_rate_limit is 'Check if user is within rate limit for an action';
-- ============================================================================
-- RATE LIMIT RECORDING FUNCTION
-- Records a rate limit event
-- ============================================================================
create or replace function public.record_rate_limit(
p_user_id uuid,
p_organization_id text,
p_action text
)
returns uuid
language plpgsql
security definer
as $$
declare
v_id uuid;
begin
insert into public.rate_limits (user_id, organization_id, action)
values (p_user_id, p_organization_id, p_action)
returning id into v_id;
return v_id;
end;
$$;
comment on function public.record_rate_limit is 'Record a rate limit event';
-- ============================================================================
-- AUDIT LOG HELPER FUNCTION
-- Creates an audit log entry
-- ============================================================================
create or replace function public.create_audit_log(
p_project_id uuid,
p_action text,
p_actor_id uuid,
p_actor_email text,
p_organization_id text,
p_metadata jsonb default null
)
returns uuid
language plpgsql
security definer
as $$
declare
v_id uuid;
begin
insert into public.project_audit_logs (
project_id,
action,
actor_id,
actor_email,
organization_id,
metadata
)
values (
p_project_id,
p_action,
p_actor_id,
p_actor_email,
p_organization_id,
p_metadata
)
returning id into v_id;
return v_id;
end;
$$;
comment on function public.create_audit_log is 'Create an audit log entry';
-- ============================================================================
-- PROJECT STATUS UPDATE FUNCTION
-- Updates project status with validation and audit logging
-- ============================================================================
create or replace function public.update_project_status(
p_project_id uuid,
p_new_status text,
p_actor_id uuid,
p_actor_email text
)
returns boolean
language plpgsql
security definer
as $$
declare
v_old_status text;
v_organization_id text;
begin
-- Validate status
if p_new_status not in ('provisioning', 'active', 'paused', 'failed', 'deleted') then
raise exception 'Invalid status: %', p_new_status;
end if;
-- Get current status and org
select status, organization_id
into v_old_status, v_organization_id
from public.projects
where id = p_project_id;
if not found then
raise exception 'Project not found: %', p_project_id;
end if;
-- Update status
update public.projects
set status = p_new_status
where id = p_project_id;
-- Create audit log
perform public.create_audit_log(
p_project_id,
'update',
p_actor_id,
p_actor_email,
v_organization_id,
jsonb_build_object(
'field', 'status',
'old_value', v_old_status,
'new_value', p_new_status
)
);
return true;
end;
$$;
comment on function public.update_project_status is 'Update project status with audit logging';
-- ============================================================================
-- SOFT DELETE FUNCTION
-- Soft deletes a project (sets deleted_at timestamp)
-- ============================================================================
create or replace function public.soft_delete_project(
p_project_id uuid,
p_actor_id uuid,
p_actor_email text
)
returns boolean
language plpgsql
security definer
as $$
declare
v_organization_id text;
begin
-- Get organization ID
select organization_id
into v_organization_id
from public.projects
where id = p_project_id;
if not found then
raise exception 'Project not found: %', p_project_id;
end if;
-- Soft delete (set deleted_at and status)
update public.projects
set
deleted_at = now(),
status = 'deleted'
where id = p_project_id
and deleted_at is null;
-- Create audit log
perform public.create_audit_log(
p_project_id,
'delete',
p_actor_id,
p_actor_email,
v_organization_id,
jsonb_build_object('soft_delete', true)
);
return true;
end;
$$;
comment on function public.soft_delete_project is 'Soft delete a project with audit logging';
-- ============================================================================
-- CLEANUP OLD RATE LIMITS
-- Function to clean up old rate limit records (run periodically)
-- ============================================================================
create or replace function public.cleanup_old_rate_limits(
p_retention_hours integer default 168 -- 7 days default
)
returns integer
language plpgsql
security definer
as $$
declare
v_deleted integer;
begin
delete from public.rate_limits
where created_at < now() - (p_retention_hours || ' hours')::interval;
get diagnostics v_deleted = row_count;
return v_deleted;
end;
$$;
comment on function public.cleanup_old_rate_limits is 'Clean up old rate limit records (7+ days)';
-- ============================================================================
-- GET USER PROJECTS COUNT
-- Returns count of active projects for a user
-- ============================================================================
create or replace function public.get_user_projects_count(
p_user_id uuid,
p_organization_id text
)
returns integer
language plpgsql
security definer
stable
as $$
declare
v_count integer;
begin
select count(*)
into v_count
from public.projects
where creator_id = p_user_id
and organization_id = p_organization_id
and deleted_at is null
and status in ('active', 'provisioning', 'paused');
return v_count;
end;
$$;
comment on function public.get_user_projects_count is 'Get count of active projects for a user';
-- ============================================================================
-- GET ORGANIZATION PROJECTS COUNT
-- Returns count of all projects in an organization
-- ============================================================================
create or replace function public.get_org_projects_count(
p_organization_id text
)
returns integer
language plpgsql
security definer
stable
as $$
declare
v_count integer;
begin
select count(*)
into v_count
from public.projects
where organization_id = p_organization_id
and deleted_at is null
and status in ('active', 'provisioning', 'paused');
return v_count;
end;
$$;
comment on function public.get_org_projects_count is 'Get count of all projects in an organization';
-- ============================================================================
-- GRANT EXECUTE PERMISSIONS
-- ============================================================================
grant execute on function public.check_rate_limit to authenticated;
grant execute on function public.record_rate_limit to authenticated;
grant execute on function public.create_audit_log to authenticated;
grant execute on function public.update_project_status to authenticated;
grant execute on function public.soft_delete_project to authenticated;
grant execute on function public.cleanup_old_rate_limits to authenticated;
grant execute on function public.get_user_projects_count to authenticated;
grant execute on function public.get_org_projects_count to authenticated;
@@ -1,123 +0,0 @@
-- Auto-assign admin role to first user per organization
-- This trigger runs after a new user signs in via SSO
-- ============================================================================
-- AUTO ADMIN ASSIGNMENT FUNCTION
-- Automatically assigns admin role to first user in an organization
-- ============================================================================
create or replace function public.auto_assign_first_admin()
returns trigger
language plpgsql
security definer
as $$
declare
v_org_id text;
v_user_count integer;
begin
-- Extract organization from user metadata (set via SSO claims)
-- Adjust this based on your SSO configuration
--
-- For Google Workspace: use 'hd' (hosted domain)
-- For Azure AD/Okta: use custom claim 'organization_id'
-- For development: use 'default_org'
v_org_id := coalesce(
new.raw_user_meta_data->>'organization_id',
new.raw_user_meta_data->>'org_id',
new.raw_user_meta_data->>'hd', -- Google Workspace hosted domain
new.raw_app_meta_data->>'organization_id',
'default_org' -- Fallback for development/testing
);
-- Count existing users in this organization
select count(*)
into v_user_count
from public.user_roles
where organization_id = v_org_id;
-- If this is the first user, make them admin
if v_user_count = 0 then
insert into public.user_roles (user_id, organization_id, role)
values (new.id, v_org_id, 'admin')
on conflict (user_id, organization_id) do nothing;
raise notice 'Auto-assigned admin role to user % in organization %', new.email, v_org_id;
else
-- Subsequent users get builder role by default
insert into public.user_roles (user_id, organization_id, role)
values (new.id, v_org_id, 'builder')
on conflict (user_id, organization_id) do nothing;
raise notice 'Auto-assigned builder role to user % in organization %', new.email, v_org_id;
end if;
return new;
end;
$$;
-- ============================================================================
-- TRIGGER ON AUTH.USERS
-- Fires when a new user is created (after SSO sign-in)
-- ============================================================================
create trigger on_auth_user_created
after insert on auth.users
for each row
execute function public.auto_assign_first_admin();
comment on function public.auto_assign_first_admin is 'Automatically assign admin role to first user per organization, builder to others';
-- ============================================================================
-- MANUAL ADMIN ASSIGNMENT FUNCTION (for additional admins)
-- Admins can use this to promote other users
-- ============================================================================
create or replace function public.promote_to_admin(
p_user_email text,
p_organization_id text
)
returns boolean
language plpgsql
security definer
as $$
declare
v_user_id uuid;
v_caller_is_admin boolean;
begin
-- Check if caller is admin
select exists(
select 1
from public.user_roles
where user_id = auth.uid()
and organization_id = p_organization_id
and role = 'admin'
) into v_caller_is_admin;
if not v_caller_is_admin then
raise exception 'Only admins can promote users';
end if;
-- Get user ID from email
select id into v_user_id
from auth.users
where email = p_user_email;
if v_user_id is null then
raise exception 'User not found: %', p_user_email;
end if;
-- Update role to admin
update public.user_roles
set role = 'admin',
assigned_by = auth.uid(),
assigned_at = now()
where user_id = v_user_id
and organization_id = p_organization_id;
return true;
end;
$$;
grant execute on function public.promote_to_admin to authenticated;
comment on function public.promote_to_admin is 'Allow admins to promote other users to admin role';
@@ -1,222 +0,0 @@
-- Fix RLS infinite recursion in user_roles table
-- The issue: policies were querying user_roles to check permissions on user_roles
-- ============================================================================
-- DROP EXISTING PROBLEMATIC POLICIES
-- ============================================================================
drop policy if exists "Admins can view organization roles" on public.user_roles;
drop policy if exists "Admins can assign roles" on public.user_roles;
drop policy if exists "Admins can update roles" on public.user_roles;
drop policy if exists "Admins can delete roles" on public.user_roles;
-- ============================================================================
-- SIMPLIFIED USER_ROLES POLICIES (NO RECURSION)
-- ============================================================================
-- Policy: Users can view their own role (simple, no recursion)
-- This policy already exists and is fine
-- create policy "Users can view their own roles"
-- on public.user_roles
-- for select
-- to authenticated
-- using (user_id = auth.uid());
-- Policy: Users can view all roles in their org (simplified)
-- We'll allow users to see all roles in orgs they belong to
create policy "Users can view roles in their organizations"
on public.user_roles
for select
to authenticated
using (
organization_id in (
select organization_id
from public.user_roles
where user_id = auth.uid()
)
);
-- Policy: Allow inserts (validation handled by helper functions/triggers)
create policy "Allow role assignments"
on public.user_roles
for insert
to authenticated
with check (true); -- Will be validated by application logic
-- Policy: Allow updates (validation handled by helper functions)
create policy "Allow role updates"
on public.user_roles
for update
to authenticated
using (true)
with check (true); -- Will be validated by application logic
-- Policy: Allow deletes (validation handled by helper functions)
create policy "Allow role deletions"
on public.user_roles
for delete
to authenticated
using (user_id != auth.uid()); -- Can't delete own role
-- ============================================================================
-- UPDATE HELPER FUNCTIONS TO HANDLE PERMISSIONS
-- ============================================================================
-- These functions run as SECURITY DEFINER and bypass RLS
-- They should handle the actual permission checks
-- Update the promote_to_admin function to check permissions properly
create or replace function public.promote_to_admin(
p_user_email text,
p_organization_id text
)
returns boolean
language plpgsql
security definer
set search_path = public
as $$
declare
v_user_id uuid;
v_caller_role text;
begin
-- Get caller's role (this function bypasses RLS)
select role into v_caller_role
from public.user_roles
where user_id = auth.uid()
and organization_id = p_organization_id;
-- Check if caller is admin
if v_caller_role != 'admin' then
raise exception 'Only admins can promote users';
end if;
-- Get user ID from email
select id into v_user_id
from auth.users
where email = p_user_email;
if v_user_id is null then
raise exception 'User not found: %', p_user_email;
end if;
-- Update role to admin
update public.user_roles
set role = 'admin',
assigned_by = auth.uid(),
assigned_at = now()
where user_id = v_user_id
and organization_id = p_organization_id;
return true;
end;
$$;
-- ============================================================================
-- UPDATE PROJECTS POLICIES TO USE SECURITY DEFINER FUNCTIONS
-- ============================================================================
-- Drop and recreate admin-related policies for projects table
drop policy if exists "Admins can view all organization projects" on public.projects;
drop policy if exists "Admins can update organization projects" on public.projects;
-- Simplified admin view policy
create policy "Admins can view all organization projects"
on public.projects
for select
to authenticated
using (
deleted_at is null
and (
creator_id = auth.uid() -- Own projects
or
exists (
select 1
from public.user_roles
where user_roles.user_id = auth.uid()
and user_roles.organization_id = projects.organization_id
and user_roles.role = 'admin'
)
)
);
-- Simplified admin update policy
create policy "Admins can update organization projects"
on public.projects
for update
to authenticated
using (
creator_id = auth.uid() -- Own projects
or
exists (
select 1
from public.user_roles
where user_roles.user_id = auth.uid()
and user_roles.organization_id = projects.organization_id
and user_roles.role = 'admin'
)
)
with check (
creator_id = auth.uid() -- Own projects
or
exists (
select 1
from public.user_roles
where user_roles.user_id = auth.uid()
and user_roles.organization_id = projects.organization_id
and user_roles.role = 'admin'
)
);
-- ============================================================================
-- UPDATE AUDIT LOGS POLICIES
-- ============================================================================
drop policy if exists "Admins can view organization audit logs" on public.project_audit_logs;
create policy "Admins can view organization audit logs"
on public.project_audit_logs
for select
to authenticated
using (
actor_id = auth.uid() -- Own actions
or
exists (
select 1
from public.projects
where projects.id = project_audit_logs.project_id
and projects.creator_id = auth.uid()
)
or
exists (
select 1
from public.user_roles
where user_roles.user_id = auth.uid()
and user_roles.organization_id = project_audit_logs.organization_id
and user_roles.role = 'admin'
)
);
-- ============================================================================
-- UPDATE RATE LIMITS POLICIES
-- ============================================================================
drop policy if exists "Admins can view organization rate limits" on public.rate_limits;
create policy "Admins can view organization rate limits"
on public.rate_limits
for select
to authenticated
using (
user_id = auth.uid() -- Own rate limits
or
exists (
select 1
from public.user_roles
where user_roles.user_id = auth.uid()
and user_roles.organization_id = rate_limits.organization_id
and user_roles.role = 'admin'
)
);
comment on policy "Users can view roles in their organizations" on public.user_roles is 'Allow users to view all roles in organizations they belong to';
@@ -1,169 +0,0 @@
-- Final fix for RLS infinite recursion
-- Solution: Make user_roles completely open for SELECT (no recursion)
-- Access control happens at the projects level, not at viewing roles
-- ============================================================================
-- DROP ALL EXISTING user_roles POLICIES
-- ============================================================================
drop policy if exists "Users can view their own roles" on public.user_roles;
drop policy if exists "Users can view roles in their organizations" on public.user_roles;
drop policy if exists "Allow role assignments" on public.user_roles;
drop policy if exists "Allow role updates" on public.user_roles;
drop policy if exists "Allow role deletions" on public.user_roles;
-- ============================================================================
-- SIMPLE NON-RECURSIVE POLICIES FOR user_roles
-- ============================================================================
-- Policy: All authenticated users can view all roles
-- This is safe because viewing roles doesn't grant access to projects
-- Project access is controlled by separate RLS policies on the projects table
create policy "Authenticated users can view all roles"
on public.user_roles
for select
to authenticated
using (true);
-- Policy: Prevent inserts via RLS (use helper functions instead)
-- This ensures role assignment goes through proper validation
create policy "No direct role inserts"
on public.user_roles
for insert
to authenticated
with check (false);
-- Policy: Prevent updates via RLS (use helper functions instead)
create policy "No direct role updates"
on public.user_roles
for update
to authenticated
using (false);
-- Policy: Prevent deletes via RLS (use helper functions instead)
create policy "No direct role deletes"
on public.user_roles
for delete
to authenticated
using (false);
-- ============================================================================
-- HELPER FUNCTION FOR ROLE ASSIGNMENT (bypasses RLS)
-- ============================================================================
-- Function to assign initial role (used by trigger)
create or replace function public.assign_user_role(
p_user_id uuid,
p_organization_id text,
p_role text
)
returns boolean
language plpgsql
security definer
set search_path = public
as $$
begin
insert into public.user_roles (user_id, organization_id, role)
values (p_user_id, p_organization_id, p_role)
on conflict (user_id, organization_id) do nothing;
return true;
end;
$$;
grant execute on function public.assign_user_role to authenticated;
comment on function public.assign_user_role is 'Assign role to user (bypasses RLS)';
-- ============================================================================
-- UPDATE AUTO-ADMIN ASSIGNMENT TRIGGER TO USE NEW FUNCTION
-- ============================================================================
create or replace function public.auto_assign_first_admin()
returns trigger
language plpgsql
security definer
set search_path = public
as $$
declare
v_org_id text;
v_user_count integer;
begin
-- Extract organization from user metadata
v_org_id := coalesce(
new.raw_user_meta_data->>'organization_id',
new.raw_user_meta_data->>'org_id',
new.raw_user_meta_data->>'hd', -- Google Workspace hosted domain
new.raw_app_meta_data->>'organization_id',
'default_org' -- Fallback for development/testing
);
-- Count existing users in this organization
select count(*)
into v_user_count
from public.user_roles
where organization_id = v_org_id;
-- Assign role based on whether this is first user
if v_user_count = 0 then
perform public.assign_user_role(new.id, v_org_id, 'admin');
raise notice 'Auto-assigned admin role to user % in organization %', new.email, v_org_id;
else
perform public.assign_user_role(new.id, v_org_id, 'builder');
raise notice 'Auto-assigned builder role to user % in organization %', new.email, v_org_id;
end if;
return new;
end;
$$;
-- ============================================================================
-- UPDATE promote_to_admin FUNCTION
-- ============================================================================
create or replace function public.promote_to_admin(
p_user_email text,
p_organization_id text
)
returns boolean
language plpgsql
security definer
set search_path = public
as $$
declare
v_user_id uuid;
v_caller_role text;
begin
-- Get caller's role (this function bypasses RLS)
select role into v_caller_role
from public.user_roles
where user_id = auth.uid()
and organization_id = p_organization_id;
-- Check if caller is admin
if v_caller_role != 'admin' then
raise exception 'Only admins can promote users';
end if;
-- Get user ID from email
select id into v_user_id
from auth.users
where email = p_user_email;
if v_user_id is null then
raise exception 'User not found: %', p_user_email;
end if;
-- Update role to admin (bypasses RLS because security definer)
update public.user_roles
set role = 'admin',
assigned_by = auth.uid(),
assigned_at = now()
where user_id = v_user_id
and organization_id = p_organization_id;
return true;
end;
$$;
comment on policy "Authenticated users can view all roles" on public.user_roles is 'Allow viewing roles - access control happens at projects level';