feat: add domain management functionality with CRUD operations and integrate into UI
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type DomainHandler struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func NewDomainHandler(db *gorm.DB) *DomainHandler {
|
||||
return &DomainHandler{DB: db}
|
||||
}
|
||||
|
||||
func (h *DomainHandler) List(c *gin.Context) {
|
||||
var domains []models.Domain
|
||||
if err := h.DB.Order("name asc").Find(&domains).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch domains"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, domains)
|
||||
}
|
||||
|
||||
func (h *DomainHandler) Create(c *gin.Context) {
|
||||
var input struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
domain := models.Domain{
|
||||
Name: input.Name,
|
||||
}
|
||||
|
||||
if err := h.DB.Create(&domain).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create domain"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, domain)
|
||||
}
|
||||
|
||||
func (h *DomainHandler) Delete(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if err := h.DB.Where("uuid = ?", id).Delete(&models.Domain{}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete domain"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Domain deleted"})
|
||||
}
|
||||
@@ -28,6 +28,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
|
||||
&models.Setting{},
|
||||
&models.ImportSession{},
|
||||
&models.Notification{},
|
||||
&models.Domain{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("auto migrate: %w", err)
|
||||
}
|
||||
@@ -94,6 +95,12 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
|
||||
protected.POST("/notifications/:id/read", notificationHandler.MarkAsRead)
|
||||
protected.POST("/notifications/read-all", notificationHandler.MarkAllAsRead)
|
||||
|
||||
// Domains
|
||||
domainHandler := handlers.NewDomainHandler(db)
|
||||
protected.GET("/domains", domainHandler.List)
|
||||
protected.POST("/domains", domainHandler.Create)
|
||||
protected.DELETE("/domains/:id", domainHandler.Delete)
|
||||
|
||||
// Docker
|
||||
dockerService, err := services.NewDockerService()
|
||||
if err == nil { // Only register if Docker is available
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Domain struct {
|
||||
ID uint `json:"id" gorm:"primarykey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex;not null"`
|
||||
Name string `json:"name" gorm:"uniqueIndex;not null"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
}
|
||||
|
||||
func (d *Domain) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if d.UUID == "" {
|
||||
d.UUID = uuid.New().String()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import SystemSettings from './pages/SystemSettings'
|
||||
import Account from './pages/Account'
|
||||
import Backups from './pages/Backups'
|
||||
import Logs from './pages/Logs'
|
||||
import Domains from './pages/Domains'
|
||||
import Login from './pages/Login'
|
||||
import Setup from './pages/Setup'
|
||||
|
||||
@@ -37,6 +38,7 @@ export default function App() {
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="proxy-hosts" element={<ProxyHosts />} />
|
||||
<Route path="remote-servers" element={<RemoteServers />} />
|
||||
<Route path="domains" element={<Domains />} />
|
||||
<Route path="certificates" element={<Certificates />} />
|
||||
<Route path="import" element={<ImportCaddy />} />
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import client from './client'
|
||||
|
||||
export interface Domain {
|
||||
id: number
|
||||
uuid: string
|
||||
name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export const getDomains = async (): Promise<Domain[]> => {
|
||||
const { data } = await client.get<Domain[]>('/domains')
|
||||
return data
|
||||
}
|
||||
|
||||
export const createDomain = async (name: string): Promise<Domain> => {
|
||||
const { data } = await client.post<Domain>('/domains', { name })
|
||||
return data
|
||||
}
|
||||
|
||||
export const deleteDomain = async (uuid: string): Promise<void> => {
|
||||
await client.delete(`/domains/${uuid}`)
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
{ name: 'Dashboard', path: '/', icon: '📊' },
|
||||
{ name: 'Proxy Hosts', path: '/proxy-hosts', icon: '🌐' },
|
||||
{ name: 'Remote Servers', path: '/remote-servers', icon: '🖥️' },
|
||||
{ name: 'Domains', path: '/domains', icon: '🌍' },
|
||||
{ name: 'Certificates', path: '/certificates', icon: '🔒' },
|
||||
{ name: 'Import Caddyfile', path: '/import', icon: '📥' },
|
||||
{ name: 'Settings', path: '/settings/system', icon: '⚙️' },
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { CircleHelp } from 'lucide-react'
|
||||
import type { ProxyHost } from '../api/proxyHosts'
|
||||
import { useRemoteServers } from '../hooks/useRemoteServers'
|
||||
import { useDomains } from '../hooks/useDomains'
|
||||
import { useDocker } from '../hooks/useDocker'
|
||||
|
||||
interface ProxyHostFormProps {
|
||||
@@ -27,7 +28,9 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
})
|
||||
|
||||
const { servers: remoteServers } = useRemoteServers()
|
||||
const [connectionSource, setConnectionSource] = useState<'local' | string>('local')
|
||||
const { domains } = useDomains()
|
||||
const [connectionSource, setConnectionSource] = useState<'local' | 'custom' | string>('custom')
|
||||
const [selectedDomain, setSelectedDomain] = useState('')
|
||||
|
||||
// Fetch containers based on selected source
|
||||
// If 'local', host is undefined (which defaults to local socket in backend)
|
||||
@@ -39,6 +42,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
|
||||
const getDockerHostString = () => {
|
||||
if (connectionSource === 'local') return undefined;
|
||||
if (connectionSource === 'custom') return undefined;
|
||||
const server = remoteServers.find(s => s.uuid === connectionSource);
|
||||
if (!server) return undefined;
|
||||
// Construct the Docker host string
|
||||
@@ -63,18 +67,6 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
}
|
||||
}
|
||||
|
||||
const handleServerSelect = (serverUuid: string) => {
|
||||
const server = remoteServers.find(s => s.uuid === serverUuid)
|
||||
if (server) {
|
||||
setFormData({
|
||||
...formData,
|
||||
forward_host: server.host,
|
||||
forward_port: server.port,
|
||||
forward_scheme: 'http',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleContainerSelect = (containerId: string) => {
|
||||
const container = dockerContainers.find(c => c.id === containerId)
|
||||
if (container) {
|
||||
@@ -83,11 +75,18 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
// Use the first exposed port if available, otherwise default to 80
|
||||
const port = container.ports && container.ports.length > 0 ? container.ports[0].private_port : 80
|
||||
|
||||
let newDomainNames = formData.domain_names
|
||||
if (selectedDomain) {
|
||||
const subdomain = container.names[0].replace(/^\//, '')
|
||||
newDomainNames = `${subdomain}.${selectedDomain}`
|
||||
}
|
||||
|
||||
setFormData({
|
||||
...formData,
|
||||
forward_host: host,
|
||||
forward_port: port,
|
||||
forward_scheme: 'http',
|
||||
domain_names: newDomainNames,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -109,42 +108,43 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
)}
|
||||
|
||||
{/* Domain Names */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Domain Names (comma-separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.domain_names}
|
||||
onChange={e => setFormData({ ...formData, domain_names: e.target.value })}
|
||||
placeholder="example.com, www.example.com"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Remote Server Quick Select */}
|
||||
{remoteServers.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{domains.length > 0 && (
|
||||
<div>
|
||||
<label htmlFor="quick-select-server" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Quick Select: Remote Server
|
||||
<label htmlFor="base-domain" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Base Domain (Auto-fill)
|
||||
</label>
|
||||
<select
|
||||
id="quick-select-server"
|
||||
onChange={e => handleServerSelect(e.target.value)}
|
||||
id="base-domain"
|
||||
value={selectedDomain}
|
||||
onChange={e => setSelectedDomain(e.target.value)}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">-- Select a server --</option>
|
||||
{remoteServers.map(server => (
|
||||
<option key={server.uuid} value={server.uuid}>
|
||||
{server.name} ({server.host}:{server.port})
|
||||
<option value="">-- Select a base domain --</option>
|
||||
{domains.map(domain => (
|
||||
<option key={domain.uuid} value={domain.name}>
|
||||
{domain.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Domain Names (comma-separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.domain_names}
|
||||
onChange={e => setFormData({ ...formData, domain_names: e.target.value })}
|
||||
placeholder="example.com, www.example.com"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Docker Container Quick Select */}
|
||||
<div>
|
||||
<label htmlFor="connection-source" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
@@ -156,6 +156,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
onChange={e => setConnectionSource(e.target.value)}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="custom">Custom / Manual</option>
|
||||
<option value="local">Local (Docker Socket)</option>
|
||||
{remoteServers
|
||||
.filter(s => s.provider === 'docker' && s.enabled)
|
||||
@@ -176,11 +177,13 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
<select
|
||||
id="quick-select-docker"
|
||||
onChange={e => handleContainerSelect(e.target.value)}
|
||||
disabled={dockerLoading}
|
||||
disabled={dockerLoading || connectionSource === 'custom'}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
<option value="">
|
||||
{dockerLoading ? 'Loading containers...' : '-- Select a container --'}
|
||||
{connectionSource === 'custom'
|
||||
? 'Select a source to view containers'
|
||||
: (dockerLoading ? 'Loading containers...' : '-- Select a container --')}
|
||||
</option>
|
||||
{dockerContainers.map(container => (
|
||||
<option key={container.id} value={container.id}>
|
||||
@@ -188,7 +191,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{dockerError && (
|
||||
{dockerError && connectionSource !== 'custom' && (
|
||||
<p className="text-xs text-red-400 mt-1">
|
||||
Failed to connect: {(dockerError as Error).message}
|
||||
</p>
|
||||
@@ -247,7 +250,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">Force SSL</span>
|
||||
<div title="Redirects all HTTP traffic to HTTPS" className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<div title="Redirects all HTTP traffic to HTTPS. This ensures that all connections to your site are encrypted, preventing eavesdropping." className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<CircleHelp size={14} />
|
||||
</div>
|
||||
</label>
|
||||
@@ -259,7 +262,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">HTTP/2 Support</span>
|
||||
<div title="Enables HTTP/2 support for better performance" className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<div title="Enables the HTTP/2 protocol. This can improve performance by allowing multiple requests to be sent over a single connection." className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<CircleHelp size={14} />
|
||||
</div>
|
||||
</label>
|
||||
@@ -271,7 +274,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">HSTS Enabled</span>
|
||||
<div title="Enables HTTP Strict Transport Security (HSTS)" className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<div title="Enables HTTP Strict Transport Security. This tells browsers to ONLY connect to your site using HTTPS for a specified period, preventing downgrade attacks." className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<CircleHelp size={14} />
|
||||
</div>
|
||||
</label>
|
||||
@@ -283,7 +286,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">HSTS Subdomains</span>
|
||||
<div title="Applies HSTS to all subdomains" className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<div title="Applies the HSTS policy to all subdomains as well. Use this only if you are sure all your subdomains support HTTPS." className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<CircleHelp size={14} />
|
||||
</div>
|
||||
</label>
|
||||
@@ -295,7 +298,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">Block Exploits</span>
|
||||
<div title="Blocks common exploit attempts (XSS, SQLi, etc.)" className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<div title="Blocks common web attacks like SQL Injection (SQLi) and Cross-Site Scripting (XSS) by filtering malicious request patterns." className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<CircleHelp size={14} />
|
||||
</div>
|
||||
</label>
|
||||
@@ -307,7 +310,7 @@ export default function ProxyHostForm({ host, onSubmit, onCancel }: ProxyHostFor
|
||||
className="w-4 h-4 text-blue-600 bg-gray-900 border-gray-700 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">Websockets Support</span>
|
||||
<div title="Enables WebSocket support for real-time applications" className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<div title="Allows WebSocket connections to pass through the proxy. This is required for real-time applications like chat apps, notifications, or live dashboards." className="text-gray-500 hover:text-gray-300 cursor-help">
|
||||
<CircleHelp size={14} />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import * as api from '../api/domains'
|
||||
|
||||
export function useDomains() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: domains = [], isLoading, error } = useQuery({
|
||||
queryKey: ['domains'],
|
||||
queryFn: api.getDomains,
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: api.createDomain,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['domains'] })
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: api.deleteDomain,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['domains'] })
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
domains,
|
||||
isLoading,
|
||||
error,
|
||||
createDomain: createMutation.mutateAsync,
|
||||
deleteDomain: deleteMutation.mutateAsync,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react'
|
||||
import { useDomains } from '../hooks/useDomains'
|
||||
import { Trash2, Plus, Globe } from 'lucide-react'
|
||||
|
||||
export default function Domains() {
|
||||
const { domains, isLoading, error, createDomain, deleteDomain } = useDomains()
|
||||
const [newDomain, setNewDomain] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newDomain.trim()) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await createDomain(newDomain)
|
||||
setNewDomain('')
|
||||
} catch (err) {
|
||||
alert('Failed to create domain')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (uuid: string) => {
|
||||
if (confirm('Are you sure you want to delete this domain?')) {
|
||||
try {
|
||||
await deleteDomain(uuid)
|
||||
} catch (err) {
|
||||
alert('Failed to delete domain')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) return <div className="p-8 text-white">Loading...</div>
|
||||
if (error) return <div className="p-8 text-red-400">Error loading domains</div>
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-3xl font-bold text-white">Domains</h1>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Add New Domain Card */}
|
||||
<div className="bg-dark-card border border-gray-800 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-white mb-4 flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
Add Domain
|
||||
</h3>
|
||||
<form onSubmit={handleAdd} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-400 mb-1">
|
||||
Domain Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newDomain}
|
||||
onChange={(e) => setNewDomain(e.target.value)}
|
||||
placeholder="example.com"
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !newDomain.trim()}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white rounded py-2 font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? 'Adding...' : 'Add Domain'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Domain List */}
|
||||
{domains.map((domain) => (
|
||||
<div key={domain.uuid} className="bg-dark-card border border-gray-800 rounded-lg p-6 flex flex-col justify-between">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-900/30 rounded-lg text-blue-400">
|
||||
<Globe size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-white">{domain.name}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Added {new Date(domain.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(domain.uuid)}
|
||||
className="text-gray-500 hover:text-red-400 transition-colors"
|
||||
title="Delete Domain"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user