From 2bfe3b7173532e333f44b01d27ae8f4d85a2dc6c Mon Sep 17 00:00:00 2001 From: Redbull Deployer Date: Sat, 7 Mar 2026 12:10:47 -0600 Subject: [PATCH] feat: add test connection feature for all external services # Conflicts: # frontend/src/lib/api.ts --- .../api/handlers/credentials_handler.go | 26 + backend/internal/router/router.go | 1 + .../internal/services/credentials_testing.go | 272 +++++++++ frontend/src/app/dashboard/settings/page.tsx | 542 +++++++++--------- frontend/src/lib/api.ts | 10 +- 5 files changed, 578 insertions(+), 273 deletions(-) create mode 100644 backend/internal/services/credentials_testing.go diff --git a/backend/internal/api/handlers/credentials_handler.go b/backend/internal/api/handlers/credentials_handler.go index faf9384..9dc80fa 100644 --- a/backend/internal/api/handlers/credentials_handler.go +++ b/backend/internal/api/handlers/credentials_handler.go @@ -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"}) +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 2cc0b73..003a2b5 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -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))) diff --git a/backend/internal/services/credentials_testing.go b/backend/internal/services/credentials_testing.go new file mode 100644 index 0000000..6649a68 --- /dev/null +++ b/backend/internal/services/credentials_testing.go @@ -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 +} diff --git a/frontend/src/app/dashboard/settings/page.tsx b/frontend/src/app/dashboard/settings/page.tsx index 6134fca..68bc9df 100644 --- a/frontend/src/app/dashboard/settings/page.tsx +++ b/frontend/src/app/dashboard/settings/page.tsx @@ -72,20 +72,7 @@ export default function SettingsPage() { const [selectedService, setSelectedService] = useState(null) const [credentialPayload, setCredentialPayload] = useState({}) 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(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({}) @@ -505,9 +509,9 @@ export default function SettingsPage() {

- {svc.service_name === 'storage' && svc.is_configured && ( - )}
@@ -548,268 +552,268 @@ export default function SettingsPage() { - {/* Stats Overview */} - {stats && ( -
- - - Total Revenue - $ - - -
${stats.monthlyRevenue?.toLocaleString() || '0'}
-

{stats.revenueGrowth ? `+${stats.revenueGrowth}% from last month` : 'This month'}

-
-
- - - Active Subscriptions - - - -
{stats.activeSubscriptions || 0}
-

{stats.subscriptionGrowth ? `+${stats.subscriptionGrowth} this week` : 'Current active'}

-
-
- - - Companies -
- - -
{stats.totalCompanies || 0}
-

Platform total

-
- - - - New (Month) - - - -
+{stats.newCompaniesThisMonth || 0}
-

Since start of month

-
-
-
- )} + {/* Stats Overview */} + {stats && ( +
+ + + Total Revenue + $ + + +
${stats.monthlyRevenue?.toLocaleString() || '0'}
+

{stats.revenueGrowth ? `+${stats.revenueGrowth}% from last month` : 'This month'}

+
+
+ + + Active Subscriptions + + + +
{stats.activeSubscriptions || 0}
+

{stats.subscriptionGrowth ? `+${stats.subscriptionGrowth} this week` : 'Current active'}

+
+
+ + + Companies +
+ + +
{stats.totalCompanies || 0}
+

Platform total

+
+ + + + New (Month) + + + +
+{stats.newCompaniesThisMonth || 0}
+

Since start of month

+
+
+
+ )} -
- - - Empresas pendentes - Aprovação e verificação de empresas. - - - - - - Empresa - Status - Ações - - - - {companies.slice(0, 5).map((company) => ( - - {company.name} - - {company.verified ? Verificada : Pendente} - - - - - - ))} - -
-
-
- - - Auditoria Recente - Últimos acessos. - - -
- {audits.slice(0, 5).map((audit) => ( -
-
-

{audit.identifier}

-

{auditDateFormatter.format(new Date(audit.createdAt))}

-
-
{audit.roles}
+
+ + + Empresas pendentes + Aprovação e verificação de empresas. + + + + + + Empresa + Status + Ações + + + + {companies.slice(0, 5).map((company) => ( + + {company.name} + + {company.verified ? Verificada : Pendente} + + + + + + ))} + +
+
+
+ + + Auditoria Recente + Últimos acessos. + + +
+ {audits.slice(0, 5).map((audit) => ( +
+
+

{audit.identifier}

+

{auditDateFormatter.format(new Date(audit.createdAt))}

+
+
{audit.roles}
+
+ ))}
- ))} -
- - -
- + + +
+ - -
- -
- - - Plans Management - Configure subscription plans. - - - - - - Name - Monthly - Yearly - Actions - - - - {plans.map((plan) => ( - - {plan.name} - ${plan.monthlyPrice} - ${plan.yearlyPrice} - - - - - - ))} - -
-
-
+ +
+ +
+ + + Plans Management + Configure subscription plans. + + + + + + Name + Monthly + Yearly + Actions + + + + {plans.map((plan) => ( + + {plan.name} + ${plan.monthlyPrice} + ${plan.yearlyPrice} + + + + + + ))} + +
+
+
- - - - {editingPlanId ? 'Edit Plan' : 'Create Plan'} - -
-
- - setPlanForm({ ...planForm, name: e.target.value })} /> -
-
- - setPlanForm({ ...planForm, description: e.target.value })} /> -
-
-
- - setPlanForm({ ...planForm, monthlyPrice: e.target.value })} /> + + + + {editingPlanId ? 'Edit Plan' : 'Create Plan'} + +
+
+ + setPlanForm({ ...planForm, name: e.target.value })} /> +
+
+ + setPlanForm({ ...planForm, description: e.target.value })} /> +
+
+
+ + setPlanForm({ ...planForm, monthlyPrice: e.target.value })} /> +
+
+ + setPlanForm({ ...planForm, yearlyPrice: e.target.value })} /> +
+
+
+ +