feat: add test connection feature for all external services

# Conflicts:
#	frontend/src/lib/api.ts
This commit is contained in:
Redbull Deployer 2026-03-07 12:10:47 -06:00
parent aae5cbefb5
commit 2bfe3b7173
5 changed files with 578 additions and 273 deletions

View file

@ -93,3 +93,29 @@ func (h *CredentialsHandler) DeleteCredential(w http.ResponseWriter, r *http.Req
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "Credentials deleted successfully"}) 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"})
}

View file

@ -246,6 +246,7 @@ func NewRouter() http.Handler {
mux.Handle("GET /api/v1/system/credentials", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(credentialsHandler.ListCredentials)))) 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("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("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) // Storage (Presigned URL)
mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL))) mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.OptionalHeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL)))

View 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
}

View file

@ -72,20 +72,7 @@ export default function SettingsPage() {
const [selectedService, setSelectedService] = useState<string | null>(null) const [selectedService, setSelectedService] = useState<string | null>(null)
const [credentialPayload, setCredentialPayload] = useState<any>({}) const [credentialPayload, setCredentialPayload] = useState<any>({})
const [isDialogOpen, setIsDialogOpen] = useState(false) const [isDialogOpen, setIsDialogOpen] = useState(false)
const [testingConnection, setTestingConnection] = useState(false) const [testingConnection, setTestingConnection] = useState<string | null>(null)
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 fetchSettings = async () => { const fetchSettings = async () => {
try { 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 // State migrated from backoffice
// const [credentialPayload, setCredentialPayload] = useState<any>({}) // const [credentialPayload, setCredentialPayload] = useState<any>({})
@ -505,9 +509,9 @@ export default function SettingsPage() {
</p> </p>
</div> </div>
<div className="flex flex-col gap-2 mt-auto"> <div className="flex flex-col gap-2 mt-auto">
{svc.service_name === 'storage' && svc.is_configured && ( {svc.is_configured && (
<Button variant="secondary" size="sm" className="w-full" onClick={handleTestStorageConnection} disabled={testingConnection}> <Button variant="secondary" size="sm" className="w-full" onClick={() => handleTestConnection(svc.service_name)} disabled={testingConnection === svc.service_name}>
{testingConnection ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : "Test Connection"} {testingConnection === svc.service_name ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : "Test Connection"}
</Button> </Button>
)} )}
<div className="flex gap-2"> <div className="flex gap-2">
@ -548,268 +552,268 @@ export default function SettingsPage() {
</TabsList> </TabsList>
<TabsContent value="dashboard" className="space-y-4"> <TabsContent value="dashboard" className="space-y-4">
{/* Stats Overview */} {/* Stats Overview */}
{stats && ( {stats && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Revenue</CardTitle> <CardTitle className="text-sm font-medium">Total Revenue</CardTitle>
<span className="text-xs text-muted-foreground">$</span> <span className="text-xs text-muted-foreground">$</span>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">${stats.monthlyRevenue?.toLocaleString() || '0'}</div> <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> <p className="text-xs text-muted-foreground">{stats.revenueGrowth ? `+${stats.revenueGrowth}% from last month` : 'This month'}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Subscriptions</CardTitle> <CardTitle className="text-sm font-medium">Active Subscriptions</CardTitle>
<CheckCircle className="h-4 w-4 text-muted-foreground" /> <CheckCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{stats.activeSubscriptions || 0}</div> <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> <p className="text-xs text-muted-foreground">{stats.subscriptionGrowth ? `+${stats.subscriptionGrowth} this week` : 'Current active'}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Companies</CardTitle> <CardTitle className="text-sm font-medium">Companies</CardTitle>
<div className="h-4 w-4 text-muted-foreground" /> <div className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{stats.totalCompanies || 0}</div> <div className="text-2xl font-bold">{stats.totalCompanies || 0}</div>
<p className="text-xs text-muted-foreground">Platform total</p> <p className="text-xs text-muted-foreground">Platform total</p>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">New (Month)</CardTitle> <CardTitle className="text-sm font-medium">New (Month)</CardTitle>
<Plus className="h-4 w-4 text-muted-foreground" /> <Plus className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">+{stats.newCompaniesThisMonth || 0}</div> <div className="text-2xl font-bold">+{stats.newCompaniesThisMonth || 0}</div>
<p className="text-xs text-muted-foreground">Since start of month</p> <p className="text-xs text-muted-foreground">Since start of month</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
)} )}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
<Card className="col-span-4"> <Card className="col-span-4">
<CardHeader> <CardHeader>
<CardTitle>Empresas pendentes</CardTitle> <CardTitle>Empresas pendentes</CardTitle>
<CardDescription>Aprovação e verificação de empresas.</CardDescription> <CardDescription>Aprovação e verificação de empresas.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Empresa</TableHead> <TableHead>Empresa</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="text-right">Ações</TableHead> <TableHead className="text-right">Ações</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{companies.slice(0, 5).map((company) => ( {companies.slice(0, 5).map((company) => (
<TableRow key={company.id}> <TableRow key={company.id}>
<TableCell className="font-medium">{company.name}</TableCell> <TableCell className="font-medium">{company.name}</TableCell>
<TableCell> <TableCell>
{company.verified ? <Badge className="bg-green-500">Verificada</Badge> : <Badge variant="secondary">Pendente</Badge>} {company.verified ? <Badge className="bg-green-500">Verificada</Badge> : <Badge variant="secondary">Pendente</Badge>}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button size="sm" variant="ghost" onClick={() => handleApproveCompany(company.id)}> <Button size="sm" variant="ghost" onClick={() => handleApproveCompany(company.id)}>
<CheckCircle className="h-4 w-4" /> <CheckCircle className="h-4 w-4" />
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</CardContent> </CardContent>
</Card> </Card>
<Card className="col-span-3"> <Card className="col-span-3">
<CardHeader> <CardHeader>
<CardTitle>Auditoria Recente</CardTitle> <CardTitle>Auditoria Recente</CardTitle>
<CardDescription>Últimos acessos.</CardDescription> <CardDescription>Últimos acessos.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-8"> <div className="space-y-8">
{audits.slice(0, 5).map((audit) => ( {audits.slice(0, 5).map((audit) => (
<div key={audit.id} className="flex items-center"> <div key={audit.id} className="flex items-center">
<div className="ml-4 space-y-1"> <div className="ml-4 space-y-1">
<p className="text-sm font-medium leading-none">{audit.identifier}</p> <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> <p className="text-xs text-muted-foreground">{auditDateFormatter.format(new Date(audit.createdAt))}</p>
</div> </div>
<div className="ml-auto font-medium text-xs text-muted-foreground">{audit.roles}</div> <div className="ml-auto font-medium text-xs text-muted-foreground">{audit.roles}</div>
</div>
))}
</div> </div>
))} </CardContent>
</div> </Card>
</CardContent> </div>
</Card> </TabsContent>
</div>
</TabsContent>
<TabsContent value="plans" className="space-y-4"> <TabsContent value="plans" className="space-y-4">
<div className="flex justify-end"> <div className="flex justify-end">
<Button onClick={() => { setEditingPlanId(null); setPlanForm({ name: "", description: "", monthlyPrice: 0, yearlyPrice: 0, features: [] }); setIsPlanDialogOpen(true) }}> <Button onClick={() => { setEditingPlanId(null); setPlanForm({ name: "", description: "", monthlyPrice: 0, yearlyPrice: 0, features: [] }); setIsPlanDialogOpen(true) }}>
<Plus className="mr-2 h-4 w-4" /> Create Plan <Plus className="mr-2 h-4 w-4" /> Create Plan
</Button> </Button>
</div> </div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Plans Management</CardTitle> <CardTitle>Plans Management</CardTitle>
<CardDescription>Configure subscription plans.</CardDescription> <CardDescription>Configure subscription plans.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead>Monthly</TableHead> <TableHead>Monthly</TableHead>
<TableHead>Yearly</TableHead> <TableHead>Yearly</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{plans.map((plan) => ( {plans.map((plan) => (
<TableRow key={plan.id}> <TableRow key={plan.id}>
<TableCell className="font-medium">{plan.name}</TableCell> <TableCell className="font-medium">{plan.name}</TableCell>
<TableCell>${plan.monthlyPrice}</TableCell> <TableCell>${plan.monthlyPrice}</TableCell>
<TableCell>${plan.yearlyPrice}</TableCell> <TableCell>${plan.yearlyPrice}</TableCell>
<TableCell className="text-right space-x-2"> <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="outline" onClick={() => { setEditingPlanId(plan.id); setPlanForm({ ...plan }); setIsPlanDialogOpen(true) }}>Edit</Button>
<Button size="sm" variant="destructive" onClick={() => handleDeletePlan(plan.id)}>Delete</Button> <Button size="sm" variant="destructive" onClick={() => handleDeletePlan(plan.id)}>Delete</Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</CardContent> </CardContent>
</Card> </Card>
<Dialog open={isPlanDialogOpen} onOpenChange={setIsPlanDialogOpen}> <Dialog open={isPlanDialogOpen} onOpenChange={setIsPlanDialogOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{editingPlanId ? 'Edit Plan' : 'Create Plan'}</DialogTitle> <DialogTitle>{editingPlanId ? 'Edit Plan' : 'Create Plan'}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Name</Label> <Label>Name</Label>
<Input value={planForm.name} onChange={(e) => setPlanForm({ ...planForm, name: e.target.value })} /> <Input value={planForm.name} onChange={(e) => setPlanForm({ ...planForm, name: e.target.value })} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Description</Label> <Label>Description</Label>
<Input value={planForm.description} onChange={(e) => setPlanForm({ ...planForm, description: e.target.value })} /> <Input value={planForm.description} onChange={(e) => setPlanForm({ ...planForm, description: e.target.value })} />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label>Monthly Price</Label> <Label>Monthly Price</Label>
<Input type="number" value={planForm.monthlyPrice} onChange={(e) => setPlanForm({ ...planForm, monthlyPrice: e.target.value })} /> <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>
<div className="grid gap-2"> <DialogFooter>
<Label>Yearly Price</Label> <Button variant="outline" onClick={() => setIsPlanDialogOpen(false)}>Cancel</Button>
<Input type="number" value={planForm.yearlyPrice} onChange={(e) => setPlanForm({ ...planForm, yearlyPrice: e.target.value })} /> <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> </CardContent>
<div className="grid gap-2"> </Card>
<Label>Features (comma separated)</Label> </TabsContent>
<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>
<TabsContent value="stripe" className="space-y-4"> <TabsContent value="system" className="space-y-4">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Stripe Integration</CardTitle> <CardTitle>System & Caching</CardTitle>
<CardDescription>Manage subscriptions and payments directly in Stripe Dashboard.</CardDescription> <CardDescription>Maintenance tasks.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent>
<div className="p-4 border rounded bg-muted/20"> <div className="flex items-center justify-between p-4 border rounded">
<p className="text-sm"> <div>
For security and advanced management (refunds, disputes, tax settings), please use the official Stripe Dashboard. <p className="font-medium">Cloudflare Cache</p>
</p> <p className="text-sm text-muted-foreground">Purge all cached files from the edge.</p>
<div className="mt-4"> </div>
<a href="https://dashboard.stripe.com" target="_blank" rel="noreferrer"> <Button variant="outline" onClick={handlePurgeCache}>Purge Cache</Button>
<Button variant="outline"> </div>
Open Stripe Dashboard <ExternalLink className="ml-2 h-4 w-4" /> </CardContent>
</Button> </Card>
</a>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="system" className="space-y-4"> <Card>
<Card> <CardHeader>
<CardHeader> <CardTitle>Tags Management</CardTitle>
<CardTitle>System & Caching</CardTitle> </CardHeader>
<CardDescription>Maintenance tasks.</CardDescription> <CardContent>
</CardHeader> {/* Reusing existing Tags Table logic here if desired, or keep it in a sub-section */}
<CardContent> <div className="flex flex-col md:flex-row gap-4 mb-4">
<div className="flex items-center justify-between p-4 border rounded"> <Input placeholder="New Tag" value={tagForm.name} onChange={(e) => setTagForm({ ...tagForm, name: e.target.value })} />
<div> <Select value={tagForm.category} onValueChange={(val: any) => setTagForm({ ...tagForm, category: val })}>
<p className="font-medium">Cloudflare Cache</p> <SelectTrigger className="w-40"><SelectValue placeholder="Category" /></SelectTrigger>
<p className="text-sm text-muted-foreground">Purge all cached files from the edge.</p> <SelectContent>
</div> <SelectItem value="area">Area</SelectItem>
<Button variant="outline" onClick={handlePurgeCache}>Purge Cache</Button> <SelectItem value="level">Level</SelectItem>
</div> <SelectItem value="stack">Stack</SelectItem>
</CardContent> </SelectContent>
</Card> </Select>
<Button onClick={handleCreateTag} disabled={creatingTag}><Plus className="mr-2 h-4 w-4" /> Add</Button>
<Card> </div>
<CardHeader> <Table>
<CardTitle>Tags Management</CardTitle> <TableHeader>
</CardHeader> <TableRow>
<CardContent> <TableHead>Tag</TableHead>
{/* Reusing existing Tags Table logic here if desired, or keep it in a sub-section */} <TableHead>Category</TableHead>
<div className="flex flex-col md:flex-row gap-4 mb-4"> <TableHead>Status</TableHead>
<Input placeholder="New Tag" value={tagForm.name} onChange={(e) => setTagForm({ ...tagForm, name: e.target.value })} /> <TableHead className="text-right">Action</TableHead>
<Select value={tagForm.category} onValueChange={(val: any) => setTagForm({ ...tagForm, category: val })}> </TableRow>
<SelectTrigger className="w-40"><SelectValue placeholder="Category" /></SelectTrigger> </TableHeader>
<SelectContent> <TableBody>
<SelectItem value="area">Area</SelectItem> {tags.map((tag) => (
<SelectItem value="level">Level</SelectItem> <TableRow key={tag.id}>
<SelectItem value="stack">Stack</SelectItem> <TableCell>{tag.name}</TableCell>
</SelectContent> <TableCell>{tag.category}</TableCell>
</Select> <TableCell>{tag.active ? <Badge className="bg-green-500">Active</Badge> : <Badge variant="outline">Inactive</Badge>}</TableCell>
<Button onClick={handleCreateTag} disabled={creatingTag}><Plus className="mr-2 h-4 w-4" /> Add</Button> <TableCell className="text-right">
</div> <Button size="sm" variant="ghost" onClick={() => handleToggleTag(tag)}>Toggle</Button>
<Table> </TableCell>
<TableHeader> </TableRow>
<TableRow> ))}
<TableHead>Tag</TableHead> </TableBody>
<TableHead>Category</TableHead> </Table>
<TableHead>Status</TableHead> </CardContent>
<TableHead className="text-right">Action</TableHead> </Card>
</TableRow> </TabsContent>
</TableHeader> </Tabs>
<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> </TabsContent>
</Tabs> </Tabs>
</TabsContent>
</Tabs>
<ConfirmModal <ConfirmModal
isOpen={!!deletePlanId} isOpen={!!deletePlanId}
onClose={() => setDeletePlanId(null)} onClose={() => setDeletePlanId(null)}

View file

@ -68,7 +68,6 @@ async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promi
(error as any).silent = true; (error as any).silent = true;
throw error; throw error;
} }
throw new Error(await getErrorMessage(response)); throw new Error(await getErrorMessage(response));
} }
@ -836,7 +835,7 @@ async function backofficeRequest<T>(endpoint: string, options: RequestInit = {})
const headers: Record<string, string> = { const headers: Record<string, string> = {
"Content-Type": "application/json", "Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers as Record<string, string>, ...options.headers as Record<string, string>,
}; };
@ -1017,6 +1016,9 @@ export const credentialsApi = {
delete: (serviceName: string) => apiRequest<void>(`/api/v1/system/credentials/${serviceName}`, { delete: (serviceName: string) => apiRequest<void>(`/api/v1/system/credentials/${serviceName}`, {
method: "DELETE", method: "DELETE",
}), }),
test: (serviceName: string) => apiRequest<{ message: string }>(`/api/v1/system/credentials/${serviceName}/test`, {
method: "POST",
}),
}; };