mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 04:15:04 +03:00
Supabase UI library, delete, squash migrations
This commit is contained in:
1 parent
b78d05b89f
commit
c9989efef9
83 files changed
+18907
-1820
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: ["item1", "item2"]
|
||||
{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
|
||||
? `["item1", "item2"] or leave empty for null`
|
||||
: `["item1", "item2"]`
|
||||
}
|
||||
{...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'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
|
||||
}
|
||||
@@ -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.')
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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.')
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
+7478
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}
|
||||
`
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Generated
+3156
-31
File diff suppressed because it is too large.
Load diff
@@ -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';
|
||||
Reference in new issue
Block a user