-
-
-
-
-
)}
-
+
{!success && (
Save configuration first before deploying
diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx
new file mode 100644
index 0000000..e0cc404
--- /dev/null
+++ b/src/app/dashboard/settings/page.tsx
@@ -0,0 +1,271 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { useRouter } from 'next/navigation'
+import Link from 'next/link'
+import Image from 'next/image'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+
+export default function SettingsPage() {
+ const [domain, setDomain] = useState('')
+ const [domainVerified, setDomainVerified] = useState(false)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState('')
+ const [success, setSuccess] = useState('')
+ const [initialLoading, setInitialLoading] = useState(true)
+ const router = useRouter()
+
+ // Load current panel domain on mount
+ useEffect(() => {
+ const loadSettings = async () => {
+ try {
+ const response = await fetch('/api/settings/panel-domain')
+ if (response.ok) {
+ const data = await response.json()
+ if (data.domain) {
+ setDomain(data.domain)
+ setDomainVerified(data.verified)
+ }
+ } else if (response.status === 401) {
+ router.push('/auth/login')
+ }
+ } catch (error) {
+ console.error('Failed to load settings:', error)
+ } finally {
+ setInitialLoading(false)
+ }
+ }
+
+ loadSettings()
+ }, [router])
+
+ const handleSaveDomain = async () => {
+ if (!domain.trim()) {
+ setError('Please enter a domain')
+ return
+ }
+
+ setLoading(true)
+ setError('')
+ setSuccess('')
+
+ try {
+ const response = await fetch('/api/settings/panel-domain', {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ domain: domain.trim() }),
+ })
+
+ const data = await response.json()
+
+ if (response.ok) {
+ setDomainVerified(data.verified)
+ setSuccess(data.message)
+ } else {
+ setError(data.error || 'Failed to save domain')
+ }
+ } catch {
+ setError('An error occurred. Please try again.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleRemoveDomain = async () => {
+ setLoading(true)
+ setError('')
+ setSuccess('')
+
+ try {
+ const response = await fetch('/api/settings/panel-domain', {
+ method: 'DELETE',
+ })
+
+ if (response.ok) {
+ setDomain('')
+ setDomainVerified(false)
+ setSuccess('Panel domain removed successfully')
+ } else {
+ const data = await response.json()
+ setError(data.error || 'Failed to remove domain')
+ }
+ } catch {
+ setError('An error occurred. Please try again.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (initialLoading) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Panel Settings
+
+ Configure your SupaPanel dashboard settings
+
+
+
+ {success && (
+
+ {success}
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {/* Panel Domain Configuration */}
+
+
+ 🌐 Panel Domain
+
+ Configure a custom domain for accessing this SupaPanel dashboard.
+ Traefik will automatically provision SSL certificates via Let's Encrypt.
+
+
+
+
+
+
How Panel Domain Works
+
+ - Point your domain's DNS A record to this server's IP address
+ - Enter your domain below (e.g.,
panel.example.com)
+ - Traefik will automatically provision an SSL certificate
+ - Access your panel at
https://panel.example.com
+
+
+
+
+
+
+
setDomain(e.target.value)}
+ />
+ {domain && (
+
+ {domainVerified ? '✓ Verified' : '⏳ Pending'}
+
+ )}
+
+
+ Enter the domain you want to use for this SupaPanel dashboard (without https://)
+
+
+
+ {domain && (
+
+
+
+ https://{domain}
+
+
+ )}
+
+
+
+ {domain && (
+
+ )}
+
+
+ {!domainVerified && domain && (
+
+ DNS Not Verified: Make sure your domain's A record points to this server's IP address.
+ DNS propagation can take up to 48 hours. You can still access the panel via IP:3000 in the meantime.
+
+ )}
+
+
+
+
+ {/* Current Access Info */}
+
+
+ 📌 Access Information
+
+ Current ways to access your SupaPanel dashboard
+
+
+
+
+
+ Direct IP Access:
+ http://YOUR_SERVER_IP:3000
+
+ {domain && domainVerified && (
+
+ )}
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/lib/traefik.ts b/src/lib/traefik.ts
index 05d159b..62a1b93 100644
--- a/src/lib/traefik.ts
+++ b/src/lib/traefik.ts
@@ -8,32 +8,35 @@ import * as path from 'path'
// Get the base path for Traefik dynamic configs
function getTraefikDynamicPath(): string {
- const mode = process.env.SUPAPANEL_MODE || 'development'
- if (mode === 'production') {
- return '/etc/supapanel/traefik/dynamic'
- }
- return path.join(process.cwd(), 'traefik', 'dynamic')
+ const mode = process.env.SUPAPANEL_MODE || 'development'
+ if (mode === 'production') {
+ return '/etc/supapanel/traefik/dynamic'
+ }
+ return path.join(process.cwd(), 'traefik', 'dynamic')
}
interface TraefikConfig {
- projectSlug: string
- domain: string
- kongPort: number
- studioPort: number
+ projectSlug: string
+ domain: string // API domain (Kong)
+ studioDomain?: string // Studio domain (optional, separate from API)
+ kongPort: number
+ studioPort: number
}
/**
* Generate Traefik routing configuration for a Supabase project
+ * Supports separate domains for API (Kong) and Studio
*/
export async function generateProjectTraefikConfig(config: TraefikConfig): Promise
{
- const { projectSlug, domain, kongPort, studioPort } = config
+ const { projectSlug, domain, studioDomain, kongPort, studioPort } = config
- const traefikConfig = `# Auto-generated Traefik routing for project: ${projectSlug}
-# Domain: ${domain}
-# Generated at: ${new Date().toISOString()}
+ // Build routers section based on which domains are configured
+ let routersConfig = ''
+ let servicesConfig = ''
-http:
- routers:
+ // API domain configuration
+ if (domain) {
+ routersConfig += `
# Main API router (Kong gateway)
${projectSlug}-api:
rule: "Host(\`${domain}\`)"
@@ -44,17 +47,7 @@ http:
certResolver: letsencrypt
priority: 10
- # Studio router (subdomain)
- ${projectSlug}-studio:
- rule: "Host(\`studio.${domain}\`)"
- entryPoints:
- - websecure
- service: ${projectSlug}-studio
- tls:
- certResolver: letsencrypt
- priority: 10
-
- # HTTP redirect routers
+ # HTTP redirect for API
${projectSlug}-api-http:
rule: "Host(\`${domain}\`)"
entryPoints:
@@ -62,16 +55,58 @@ http:
middlewares:
- https-redirect
service: ${projectSlug}-api
- priority: 5
+ priority: 5`
+
+ servicesConfig += `
+ ${projectSlug}-api:
+ loadBalancer:
+ servers:
+ - url: "http://${projectSlug}-kong:${kongPort}"
+ healthCheck:
+ path: /health
+ interval: 30s
+ timeout: 5s`
+ }
+
+ // Studio domain configuration
+ if (studioDomain) {
+ routersConfig += `
+
+ # Studio router
+ ${projectSlug}-studio:
+ rule: "Host(\`${studioDomain}\`)"
+ entryPoints:
+ - websecure
+ service: ${projectSlug}-studio
+ tls:
+ certResolver: letsencrypt
+ priority: 10
+ # HTTP redirect for Studio
${projectSlug}-studio-http:
- rule: "Host(\`studio.${domain}\`)"
+ rule: "Host(\`${studioDomain}\`)"
entryPoints:
- web
middlewares:
- https-redirect
service: ${projectSlug}-studio
- priority: 5
+ priority: 5`
+
+ servicesConfig += `
+
+ ${projectSlug}-studio:
+ loadBalancer:
+ servers:
+ - url: "http://${projectSlug}-studio:${studioPort}"`
+ }
+
+ const traefikConfig = `# Auto-generated Traefik routing for project: ${projectSlug}
+# API Domain: ${domain || 'not configured'}
+# Studio Domain: ${studioDomain || 'not configured'}
+# Generated at: ${new Date().toISOString()}
+
+http:
+ routers:${routersConfig}
middlewares:
https-redirect:
@@ -79,75 +114,62 @@ http:
scheme: https
permanent: true
- services:
- ${projectSlug}-api:
- loadBalancer:
- servers:
- - url: "http://${projectSlug}-kong:${kongPort}"
- healthCheck:
- path: /health
- interval: 30s
- timeout: 5s
-
- ${projectSlug}-studio:
- loadBalancer:
- servers:
- - url: "http://${projectSlug}-studio:${studioPort}"
+ services:${servicesConfig}
`
- const configPath = path.join(getTraefikDynamicPath(), `${projectSlug}.yml`)
+ const configPath = path.join(getTraefikDynamicPath(), `${projectSlug}.yml`)
- // Ensure directory exists
- await fs.mkdir(getTraefikDynamicPath(), { recursive: true })
+ // Ensure directory exists
+ await fs.mkdir(getTraefikDynamicPath(), { recursive: true })
- // Write the config file
- await fs.writeFile(configPath, traefikConfig)
+ // Write the config file
+ await fs.writeFile(configPath, traefikConfig)
- console.log(`Traefik config generated for project ${projectSlug} at ${configPath}`)
+ console.log(`Traefik config generated for project ${projectSlug} at ${configPath}`)
}
/**
* Update an existing project's Traefik config with a new domain
*/
export async function updateProjectDomain(
- projectSlug: string,
- newDomain: string,
- kongPort: number,
- studioPort: number
+ projectSlug: string,
+ newDomain: string,
+ kongPort: number,
+ studioPort: number
): Promise {
- await generateProjectTraefikConfig({
- projectSlug,
- domain: newDomain,
- kongPort,
- studioPort,
- })
+ await generateProjectTraefikConfig({
+ projectSlug,
+ domain: newDomain,
+ kongPort,
+ studioPort,
+ })
}
/**
* Remove Traefik configuration for a deleted project
*/
export async function removeProjectTraefikConfig(projectSlug: string): Promise {
- const configPath = path.join(getTraefikDynamicPath(), `${projectSlug}.yml`)
-
- try {
- await fs.unlink(configPath)
- console.log(`Traefik config removed for project ${projectSlug}`)
- } catch (error) {
- // File may not exist if project never had a custom domain
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
- console.error(`Failed to remove Traefik config for ${projectSlug}:`, error)
- }
+ const configPath = path.join(getTraefikDynamicPath(), `${projectSlug}.yml`)
+
+ try {
+ await fs.unlink(configPath)
+ console.log(`Traefik config removed for project ${projectSlug}`)
+ } catch (error) {
+ // File may not exist if project never had a custom domain
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
+ console.error(`Failed to remove Traefik config for ${projectSlug}:`, error)
}
+ }
}
/**
* Get the ports used by a project's services from environment variables
*/
export function getProjectPorts(envVars: Record): { kongPort: number; studioPort: number } {
- return {
- kongPort: parseInt(envVars.KONG_HTTP_PORT || '8000', 10),
- studioPort: parseInt(envVars.STUDIO_PORT || '3000', 10),
- }
+ return {
+ kongPort: parseInt(envVars.KONG_HTTP_PORT || '8000', 10),
+ studioPort: parseInt(envVars.STUDIO_PORT || '3000', 10),
+ }
}
/**
@@ -155,15 +177,89 @@ export function getProjectPorts(envVars: Record): { kongPort: nu
* Returns true if the domain resolves to an IP that could be this server
*/
export async function verifyDomainDNS(domain: string): Promise {
- try {
- // Use Node's DNS module for resolution
- const dns = await import('dns').then(m => m.promises)
- const addresses = await dns.resolve4(domain)
-
- // Domain resolves to at least one IP
- return addresses.length > 0
- } catch {
- // DNS resolution failed
- return false
+ try {
+ // Use Node's DNS module for resolution
+ const dns = await import('dns').then(m => m.promises)
+ const addresses = await dns.resolve4(domain)
+
+ // Domain resolves to at least one IP
+ return addresses.length > 0
+ } catch {
+ // DNS resolution failed
+ return false
+ }
+}
+
+/**
+ * Generate Traefik routing configuration for the SupaPanel itself
+ */
+export async function generatePanelTraefikConfig(domain: string): Promise {
+ const traefikConfig = `# Auto-generated Traefik routing for SupaPanel
+# Domain: ${domain}
+# Generated at: ${new Date().toISOString()}
+
+http:
+ routers:
+ # Panel HTTPS router
+ supapanel:
+ rule: "Host(\`${domain}\`)"
+ entryPoints:
+ - websecure
+ service: supapanel
+ tls:
+ certResolver: letsencrypt
+ priority: 100
+
+ # Panel HTTP redirect router
+ supapanel-http:
+ rule: "Host(\`${domain}\`)"
+ entryPoints:
+ - web
+ middlewares:
+ - https-redirect
+ service: supapanel
+ priority: 50
+
+ middlewares:
+ https-redirect:
+ redirectScheme:
+ scheme: https
+ permanent: true
+
+ services:
+ supapanel:
+ loadBalancer:
+ servers:
+ - url: "http://supapanel-panel:3000"
+`
+
+ const configPath = path.join(getTraefikDynamicPath(), 'panel.yml')
+
+ // Ensure directory exists
+ await fs.mkdir(getTraefikDynamicPath(), { recursive: true })
+
+ // Write the config file
+ await fs.writeFile(configPath, traefikConfig)
+
+ console.log(`Panel Traefik config generated at ${configPath}`)
+}
+
+/**
+ * Remove Traefik configuration for the panel domain
+ */
+export async function removePanelTraefikConfig(): Promise {
+ const configPath = path.join(getTraefikDynamicPath(), 'panel.yml')
+
+ try {
+ // Write empty config (just a comment)
+ const emptyConfig = `# SupaPanel Panel Routing
+# No custom domain configured - access via IP:3000
+`
+ await fs.writeFile(configPath, emptyConfig)
+ console.log(`Panel Traefik config cleared at ${configPath}`)
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
+ console.error('Failed to remove panel Traefik config:', error)
}
+ }
}
diff --git a/traefik/dynamic/panel.yml b/traefik/dynamic/panel.yml
deleted file mode 100644
index 1d383c6..0000000
--- a/traefik/dynamic/panel.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-# Panel Routing Configuration
-# This file is created by the install script and can be customized for panel domain
-
-http:
- routers:
- panel-http:
- rule: "PathPrefix(`/`)"
- entryPoints:
- - web
- service: panel
- priority: 1
-
- services:
- panel:
- loadBalancer:
- servers:
- - url: "http://supapanel-panel:3000"