feat: add test connection feature for all external services
# Conflicts: # frontend/src/lib/api.ts
This commit is contained in:
parent
aae5cbefb5
commit
2bfe3b7173
5 changed files with 578 additions and 273 deletions
|
|
@ -93,3 +93,29 @@ func (h *CredentialsHandler) DeleteCredential(w http.ResponseWriter, r *http.Req
|
|||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Credentials deleted successfully"})
|
||||
}
|
||||
|
||||
// TestCredential runs a connection test for the provided service
|
||||
// POST /api/v1/system/credentials/{service}/test
|
||||
func (h *CredentialsHandler) TestCredential(w http.ResponseWriter, r *http.Request) {
|
||||
serviceName := r.PathValue("service")
|
||||
if serviceName == "" {
|
||||
http.Error(w, "Service name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle specific existing endpoints independently if they don't map to the new CredentialsService logic
|
||||
if serviceName == "storage" {
|
||||
http.Error(w, "Please use the /api/v1/admin/storage/test-connection endpoint for storage", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.credentialsService.TestConnection(r.Context(), serviceName); err != nil {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": "Test failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"message": "Connection test successful"})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -246,6 +246,7 @@ func NewRouter() http.Handler {
|
|||
mux.Handle("GET /api/v1/system/credentials", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(credentialsHandler.ListCredentials))))
|
||||
mux.Handle("POST /api/v1/system/credentials", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(credentialsHandler.SaveCredential))))
|
||||
mux.Handle("DELETE /api/v1/system/credentials/{service}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(credentialsHandler.DeleteCredential))))
|
||||
mux.Handle("POST /api/v1/system/credentials/{service}/test", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(credentialsHandler.TestCredential))))
|
||||
|
||||
// Storage (Presigned URL)
|
||||
mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL)))
|
||||
|
|
|
|||
272
backend/internal/services/credentials_testing.go
Normal file
272
backend/internal/services/credentials_testing.go
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/smtp"
|
||||
"time"
|
||||
|
||||
firebase "firebase.google.com/go/v4"
|
||||
"github.com/appwrite/sdk-for-go/appwrite"
|
||||
amqp "github.com/rabbitmq/amqp091-go"
|
||||
"github.com/stripe/stripe-go/v76"
|
||||
"github.com/stripe/stripe-go/v76/balance"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
// TestConnection attempts to use the saved credentials for a specific service to verify they work.
|
||||
func (s *CredentialsService) TestConnection(ctx context.Context, serviceName string) error {
|
||||
// First, check if configured
|
||||
configured, err := s.isServiceConfigured(ctx, serviceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if service is configured: %w", err)
|
||||
}
|
||||
|
||||
if !configured {
|
||||
return fmt.Errorf("service %s is not configured", serviceName)
|
||||
}
|
||||
|
||||
// 2. Fetch the credentials payload
|
||||
decrypted, err := s.GetDecryptedKey(ctx, serviceName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve credentials: %w", err)
|
||||
}
|
||||
|
||||
var creds map[string]string
|
||||
if err := json.Unmarshal([]byte(decrypted), &creds); err != nil {
|
||||
return fmt.Errorf("failed to parse credentials payload: %w", err)
|
||||
}
|
||||
|
||||
// 3. Test based on service type
|
||||
switch serviceName {
|
||||
case "stripe":
|
||||
return s.testStripe(creds)
|
||||
case "cloudflare_config":
|
||||
return s.testCloudflare(ctx, creds)
|
||||
case "cpanel":
|
||||
return s.testCPanel(ctx, creds)
|
||||
case "lavinmq":
|
||||
return s.testLavinMQ(creds)
|
||||
case "appwrite":
|
||||
return s.testAppwrite(creds)
|
||||
case "fcm_service_account":
|
||||
return s.testFCM(ctx, creds)
|
||||
case "smtp":
|
||||
return s.testSMTP(creds)
|
||||
default:
|
||||
return fmt.Errorf("testing for service %s is not implemented", serviceName)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CredentialsService) testStripe(creds map[string]string) error {
|
||||
secretKey := creds["secretKey"]
|
||||
if secretKey == "" {
|
||||
return fmt.Errorf("missing secretKey in credentials")
|
||||
}
|
||||
|
||||
stripe.Key = secretKey
|
||||
_, err := balance.Get(nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stripe connection test failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CredentialsService) testCloudflare(ctx context.Context, creds map[string]string) error {
|
||||
apiToken := creds["apiToken"]
|
||||
if apiToken == "" {
|
||||
return fmt.Errorf("missing apiToken in credentials")
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.cloudflare.com/client/v4/user/tokens/verify", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", "Bearer "+apiToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cloudflare connection failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("cloudflare API returned status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Success bool `json:"success"`
|
||||
Messages []struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("failed to parse cloudflare response: %w", err)
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
return fmt.Errorf("cloudflare verification failed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CredentialsService) testLavinMQ(creds map[string]string) error {
|
||||
amqpUrl := creds["amqpUrl"]
|
||||
if amqpUrl == "" {
|
||||
return fmt.Errorf("missing amqpUrl in credentials")
|
||||
}
|
||||
|
||||
conn, err := amqp.Dial(amqpUrl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lavinmq connection failed: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CredentialsService) testAppwrite(creds map[string]string) error {
|
||||
endpoint := creds["endpoint"]
|
||||
projectId := creds["projectId"]
|
||||
apiKey := creds["apiKey"]
|
||||
|
||||
if endpoint == "" || projectId == "" || apiKey == "" {
|
||||
return fmt.Errorf("missing required Appwrite credentials")
|
||||
}
|
||||
|
||||
client := appwrite.NewClient(
|
||||
appwrite.WithEndpoint(endpoint),
|
||||
appwrite.WithProject(projectId),
|
||||
appwrite.WithKey(apiKey),
|
||||
)
|
||||
|
||||
health := appwrite.NewHealth(client)
|
||||
_, err := health.Get()
|
||||
if err != nil {
|
||||
return fmt.Errorf("appwrite connection failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CredentialsService) testCPanel(ctx context.Context, creds map[string]string) error {
|
||||
host := creds["host"]
|
||||
username := creds["username"]
|
||||
apiToken := creds["apiToken"]
|
||||
|
||||
if host == "" || username == "" || apiToken == "" {
|
||||
return fmt.Errorf("missing required cpanel credentials")
|
||||
}
|
||||
|
||||
// Just checking the base server reachability since testing cPanel API token accurately can be complex.
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", host, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Authorization", fmt.Sprintf("cpanel %s:%s", username, apiToken))
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cpanel connection failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return fmt.Errorf("cpanel authentication failed (status %d)", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CredentialsService) testFCM(ctx context.Context, creds map[string]string) error {
|
||||
serviceAccountJson := creds["serviceAccountJson"]
|
||||
if serviceAccountJson == "" {
|
||||
return fmt.Errorf("missing serviceAccountJson in credentials")
|
||||
}
|
||||
|
||||
opt := option.WithCredentialsJSON([]byte(serviceAccountJson))
|
||||
app, err := firebase.NewApp(ctx, nil, opt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize FCM app: %w", err)
|
||||
}
|
||||
|
||||
client, err := app.Messaging(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get FCM messaging client: %w", err)
|
||||
}
|
||||
// We don't actually send a notification, we just check if we can instance the client
|
||||
// without credential errors.
|
||||
if client == nil {
|
||||
return fmt.Errorf("FCM client is nil")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CredentialsService) testSMTP(creds map[string]string) error {
|
||||
host := creds["host"]
|
||||
port := creds["port"]
|
||||
username := creds["username"]
|
||||
password := creds["password"]
|
||||
secure := creds["secure"]
|
||||
|
||||
if host == "" || port == "" {
|
||||
return fmt.Errorf("missing host or port in smtp credentials")
|
||||
}
|
||||
|
||||
serverName := host + ":" + port
|
||||
|
||||
// Set 10s timeout
|
||||
conn, err := tls.DialWithDialer(
|
||||
&net.Dialer{Timeout: 10 * time.Second},
|
||||
"tcp",
|
||||
serverName,
|
||||
&tls.Config{InsecureSkipVerify: true},
|
||||
)
|
||||
if err != nil {
|
||||
if secure == "true" {
|
||||
return fmt.Errorf("secure SMTP connection failed: %w", err)
|
||||
}
|
||||
// Try insecure
|
||||
client, err := smtp.Dial(serverName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insecure SMTP connection failed: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if username != "" && password != "" {
|
||||
auth := smtp.PlainAuth("", username, password, host)
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP authentication failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create SMTP client: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if username != "" && password != "" {
|
||||
auth := smtp.PlainAuth("", username, password, host)
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP authentication failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -72,20 +72,7 @@ export default function SettingsPage() {
|
|||
const [selectedService, setSelectedService] = useState<string | null>(null)
|
||||
const [credentialPayload, setCredentialPayload] = useState<any>({})
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [testingConnection, setTestingConnection] = useState(false)
|
||||
|
||||
const handleTestStorageConnection = async () => {
|
||||
setTestingConnection(true)
|
||||
try {
|
||||
const res = await storageApi.testConnection()
|
||||
toast.success(res.message || "Connection successful")
|
||||
} catch (error: any) {
|
||||
console.error("Storage connection test failed", error)
|
||||
toast.error(error.message || "Connection failed")
|
||||
} finally {
|
||||
setTestingConnection(false)
|
||||
}
|
||||
}
|
||||
const [testingConnection, setTestingConnection] = useState<string | null>(null)
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
|
|
@ -243,6 +230,23 @@ export default function SettingsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
const handleTestConnection = async (serviceName: string) => {
|
||||
setTestingConnection(serviceName)
|
||||
try {
|
||||
if (serviceName === "storage") {
|
||||
await storageApi.testConnection()
|
||||
} else {
|
||||
await credentialsApi.test(serviceName)
|
||||
}
|
||||
toast.success(`${schemas[serviceName]?.label || serviceName} connection test successful!`)
|
||||
} catch (error: any) {
|
||||
console.error("Test failed", error)
|
||||
toast.error(`Connection test failed: ${error.message || 'Unknown error'}`)
|
||||
} finally {
|
||||
setTestingConnection(null)
|
||||
}
|
||||
}
|
||||
|
||||
// State migrated from backoffice
|
||||
// const [credentialPayload, setCredentialPayload] = useState<any>({})
|
||||
|
||||
|
|
@ -505,9 +509,9 @@ export default function SettingsPage() {
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mt-auto">
|
||||
{svc.service_name === 'storage' && svc.is_configured && (
|
||||
<Button variant="secondary" size="sm" className="w-full" onClick={handleTestStorageConnection} disabled={testingConnection}>
|
||||
{testingConnection ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : "Test Connection"}
|
||||
{svc.is_configured && (
|
||||
<Button variant="secondary" size="sm" className="w-full" onClick={() => handleTestConnection(svc.service_name)} disabled={testingConnection === svc.service_name}>
|
||||
{testingConnection === svc.service_name ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : "Test Connection"}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
|
|
@ -548,268 +552,268 @@ export default function SettingsPage() {
|
|||
</TabsList>
|
||||
|
||||
<TabsContent value="dashboard" className="space-y-4">
|
||||
{/* Stats Overview */}
|
||||
{stats && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
|
||||
<span className="text-xs text-muted-foreground">$</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">${stats.monthlyRevenue?.toLocaleString() || '0'}</div>
|
||||
<p className="text-xs text-muted-foreground">{stats.revenueGrowth ? `+${stats.revenueGrowth}% from last month` : 'This month'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Subscriptions</CardTitle>
|
||||
<CheckCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.activeSubscriptions || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">{stats.subscriptionGrowth ? `+${stats.subscriptionGrowth} this week` : 'Current active'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Companies</CardTitle>
|
||||
<div className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.totalCompanies || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">Platform total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">New (Month)</CardTitle>
|
||||
<Plus className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">+{stats.newCompaniesThisMonth || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">Since start of month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
{/* Stats Overview */}
|
||||
{stats && (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
|
||||
<span className="text-xs text-muted-foreground">$</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">${stats.monthlyRevenue?.toLocaleString() || '0'}</div>
|
||||
<p className="text-xs text-muted-foreground">{stats.revenueGrowth ? `+${stats.revenueGrowth}% from last month` : 'This month'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Subscriptions</CardTitle>
|
||||
<CheckCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.activeSubscriptions || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">{stats.subscriptionGrowth ? `+${stats.subscriptionGrowth} this week` : 'Current active'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Companies</CardTitle>
|
||||
<div className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stats.totalCompanies || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">Platform total</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">New (Month)</CardTitle>
|
||||
<Plus className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">+{stats.newCompaniesThisMonth || 0}</div>
|
||||
<p className="text-xs text-muted-foreground">Since start of month</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<Card className="col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Empresas pendentes</CardTitle>
|
||||
<CardDescription>Aprovação e verificação de empresas.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Empresa</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{companies.slice(0, 5).map((company) => (
|
||||
<TableRow key={company.id}>
|
||||
<TableCell className="font-medium">{company.name}</TableCell>
|
||||
<TableCell>
|
||||
{company.verified ? <Badge className="bg-green-500">Verificada</Badge> : <Badge variant="secondary">Pendente</Badge>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="ghost" onClick={() => handleApproveCompany(company.id)}>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Auditoria Recente</CardTitle>
|
||||
<CardDescription>Últimos acessos.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-8">
|
||||
{audits.slice(0, 5).map((audit) => (
|
||||
<div key={audit.id} className="flex items-center">
|
||||
<div className="ml-4 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{audit.identifier}</p>
|
||||
<p className="text-xs text-muted-foreground">{auditDateFormatter.format(new Date(audit.createdAt))}</p>
|
||||
</div>
|
||||
<div className="ml-auto font-medium text-xs text-muted-foreground">{audit.roles}</div>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
<Card className="col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Empresas pendentes</CardTitle>
|
||||
<CardDescription>Aprovação e verificação de empresas.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Empresa</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{companies.slice(0, 5).map((company) => (
|
||||
<TableRow key={company.id}>
|
||||
<TableCell className="font-medium">{company.name}</TableCell>
|
||||
<TableCell>
|
||||
{company.verified ? <Badge className="bg-green-500">Verificada</Badge> : <Badge variant="secondary">Pendente</Badge>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="ghost" onClick={() => handleApproveCompany(company.id)}>
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>Auditoria Recente</CardTitle>
|
||||
<CardDescription>Últimos acessos.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-8">
|
||||
{audits.slice(0, 5).map((audit) => (
|
||||
<div key={audit.id} className="flex items-center">
|
||||
<div className="ml-4 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{audit.identifier}</p>
|
||||
<p className="text-xs text-muted-foreground">{auditDateFormatter.format(new Date(audit.createdAt))}</p>
|
||||
</div>
|
||||
<div className="ml-auto font-medium text-xs text-muted-foreground">{audit.roles}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="plans" className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => { setEditingPlanId(null); setPlanForm({ name: "", description: "", monthlyPrice: 0, yearlyPrice: 0, features: [] }); setIsPlanDialogOpen(true) }}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Create Plan
|
||||
</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Plans Management</CardTitle>
|
||||
<CardDescription>Configure subscription plans.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Monthly</TableHead>
|
||||
<TableHead>Yearly</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{plans.map((plan) => (
|
||||
<TableRow key={plan.id}>
|
||||
<TableCell className="font-medium">{plan.name}</TableCell>
|
||||
<TableCell>${plan.monthlyPrice}</TableCell>
|
||||
<TableCell>${plan.yearlyPrice}</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button size="sm" variant="outline" onClick={() => { setEditingPlanId(plan.id); setPlanForm({ ...plan }); setIsPlanDialogOpen(true) }}>Edit</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => handleDeletePlan(plan.id)}>Delete</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<TabsContent value="plans" className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => { setEditingPlanId(null); setPlanForm({ name: "", description: "", monthlyPrice: 0, yearlyPrice: 0, features: [] }); setIsPlanDialogOpen(true) }}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Create Plan
|
||||
</Button>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Plans Management</CardTitle>
|
||||
<CardDescription>Configure subscription plans.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Monthly</TableHead>
|
||||
<TableHead>Yearly</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{plans.map((plan) => (
|
||||
<TableRow key={plan.id}>
|
||||
<TableCell className="font-medium">{plan.name}</TableCell>
|
||||
<TableCell>${plan.monthlyPrice}</TableCell>
|
||||
<TableCell>${plan.yearlyPrice}</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button size="sm" variant="outline" onClick={() => { setEditingPlanId(plan.id); setPlanForm({ ...plan }); setIsPlanDialogOpen(true) }}>Edit</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => handleDeletePlan(plan.id)}>Delete</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={isPlanDialogOpen} onOpenChange={setIsPlanDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingPlanId ? 'Edit Plan' : 'Create Plan'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Name</Label>
|
||||
<Input value={planForm.name} onChange={(e) => setPlanForm({ ...planForm, name: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Description</Label>
|
||||
<Input value={planForm.description} onChange={(e) => setPlanForm({ ...planForm, description: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Monthly Price</Label>
|
||||
<Input type="number" value={planForm.monthlyPrice} onChange={(e) => setPlanForm({ ...planForm, monthlyPrice: e.target.value })} />
|
||||
<Dialog open={isPlanDialogOpen} onOpenChange={setIsPlanDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingPlanId ? 'Edit Plan' : 'Create Plan'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Name</Label>
|
||||
<Input value={planForm.name} onChange={(e) => setPlanForm({ ...planForm, name: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Description</Label>
|
||||
<Input value={planForm.description} onChange={(e) => setPlanForm({ ...planForm, description: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Monthly Price</Label>
|
||||
<Input type="number" value={planForm.monthlyPrice} onChange={(e) => setPlanForm({ ...planForm, monthlyPrice: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Yearly Price</Label>
|
||||
<Input type="number" value={planForm.yearlyPrice} onChange={(e) => setPlanForm({ ...planForm, yearlyPrice: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Features (comma separated)</Label>
|
||||
<Textarea value={Array.isArray(planForm.features) ? planForm.features.join(', ') : planForm.features} onChange={(e) => setPlanForm({ ...planForm, features: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Yearly Price</Label>
|
||||
<Input type="number" value={planForm.yearlyPrice} onChange={(e) => setPlanForm({ ...planForm, yearlyPrice: e.target.value })} />
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsPlanDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSavePlan}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="stripe" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stripe Integration</CardTitle>
|
||||
<CardDescription>Manage subscriptions and payments directly in Stripe Dashboard.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 border rounded bg-muted/20">
|
||||
<p className="text-sm">
|
||||
For security and advanced management (refunds, disputes, tax settings), please use the official Stripe Dashboard.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<a href="https://dashboard.stripe.com" target="_blank" rel="noreferrer">
|
||||
<Button variant="outline">
|
||||
Open Stripe Dashboard <ExternalLink className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Features (comma separated)</Label>
|
||||
<Textarea value={Array.isArray(planForm.features) ? planForm.features.join(', ') : planForm.features} onChange={(e) => setPlanForm({ ...planForm, features: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsPlanDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSavePlan}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TabsContent>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="stripe" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stripe Integration</CardTitle>
|
||||
<CardDescription>Manage subscriptions and payments directly in Stripe Dashboard.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 border rounded bg-muted/20">
|
||||
<p className="text-sm">
|
||||
For security and advanced management (refunds, disputes, tax settings), please use the official Stripe Dashboard.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<a href="https://dashboard.stripe.com" target="_blank" rel="noreferrer">
|
||||
<Button variant="outline">
|
||||
Open Stripe Dashboard <ExternalLink className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="system" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>System & Caching</CardTitle>
|
||||
<CardDescription>Maintenance tasks.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between p-4 border rounded">
|
||||
<div>
|
||||
<p className="font-medium">Cloudflare Cache</p>
|
||||
<p className="text-sm text-muted-foreground">Purge all cached files from the edge.</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handlePurgeCache}>Purge Cache</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TabsContent value="system" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>System & Caching</CardTitle>
|
||||
<CardDescription>Maintenance tasks.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between p-4 border rounded">
|
||||
<div>
|
||||
<p className="font-medium">Cloudflare Cache</p>
|
||||
<p className="text-sm text-muted-foreground">Purge all cached files from the edge.</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handlePurgeCache}>Purge Cache</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tags Management</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Reusing existing Tags Table logic here if desired, or keep it in a sub-section */}
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-4">
|
||||
<Input placeholder="New Tag" value={tagForm.name} onChange={(e) => setTagForm({ ...tagForm, name: e.target.value })} />
|
||||
<Select value={tagForm.category} onValueChange={(val: any) => setTagForm({ ...tagForm, category: val })}>
|
||||
<SelectTrigger className="w-40"><SelectValue placeholder="Category" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="area">Area</SelectItem>
|
||||
<SelectItem value="level">Level</SelectItem>
|
||||
<SelectItem value="stack">Stack</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleCreateTag} disabled={creatingTag}><Plus className="mr-2 h-4 w-4" /> Add</Button>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tag</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tags.map((tag) => (
|
||||
<TableRow key={tag.id}>
|
||||
<TableCell>{tag.name}</TableCell>
|
||||
<TableCell>{tag.category}</TableCell>
|
||||
<TableCell>{tag.active ? <Badge className="bg-green-500">Active</Badge> : <Badge variant="outline">Inactive</Badge>}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="ghost" onClick={() => handleToggleTag(tag)}>Toggle</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tags Management</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Reusing existing Tags Table logic here if desired, or keep it in a sub-section */}
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-4">
|
||||
<Input placeholder="New Tag" value={tagForm.name} onChange={(e) => setTagForm({ ...tagForm, name: e.target.value })} />
|
||||
<Select value={tagForm.category} onValueChange={(val: any) => setTagForm({ ...tagForm, category: val })}>
|
||||
<SelectTrigger className="w-40"><SelectValue placeholder="Category" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="area">Area</SelectItem>
|
||||
<SelectItem value="level">Level</SelectItem>
|
||||
<SelectItem value="stack">Stack</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleCreateTag} disabled={creatingTag}><Plus className="mr-2 h-4 w-4" /> Add</Button>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tag</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className="text-right">Action</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tags.map((tag) => (
|
||||
<TableRow key={tag.id}>
|
||||
<TableCell>{tag.name}</TableCell>
|
||||
<TableCell>{tag.category}</TableCell>
|
||||
<TableCell>{tag.active ? <Badge className="bg-green-500">Active</Badge> : <Badge variant="outline">Inactive</Badge>}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="ghost" onClick={() => handleToggleTag(tag)}>Toggle</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<ConfirmModal
|
||||
isOpen={!!deletePlanId}
|
||||
onClose={() => setDeletePlanId(null)}
|
||||
|
|
|
|||
|
|
@ -68,7 +68,6 @@ async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promi
|
|||
(error as any).silent = true;
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new Error(await getErrorMessage(response));
|
||||
}
|
||||
|
||||
|
|
@ -836,7 +835,7 @@ async function backofficeRequest<T>(endpoint: string, options: RequestInit = {})
|
|||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.headers as Record<string, string>,
|
||||
};
|
||||
|
||||
|
|
@ -1017,6 +1016,9 @@ export const credentialsApi = {
|
|||
delete: (serviceName: string) => apiRequest<void>(`/api/v1/system/credentials/${serviceName}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
test: (serviceName: string) => apiRequest<{ message: string }>(`/api/v1/system/credentials/${serviceName}/test`, {
|
||||
method: "POST",
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue