From 2e7da0b28e43fa3444dfcaec0d1c64b1bc234383 Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Wed, 31 Dec 2025 16:13:27 -0300 Subject: [PATCH] feat: add backoffice credentials page and backend support --- backend/cmd/api/main.go | 15 ++ .../internal/api/handlers/core_handlers.go | 35 ++- backend/internal/handlers/payment_handler.go | 50 +++- backend/internal/services/cpanel_service.go | 76 ++++++ .../internal/services/credentials_service.go | 128 +++++++++ .../src/app/dashboard/credentials/page.tsx | 245 ++++++++++++++++++ frontend/src/components/sidebar.tsx | 7 +- frontend/src/lib/api.ts | 2 +- 8 files changed, 532 insertions(+), 26 deletions(-) create mode 100644 backend/internal/services/cpanel_service.go create mode 100644 frontend/src/app/dashboard/credentials/page.tsx diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 6e91ad0..c829a05 100755 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -1,6 +1,8 @@ package main import ( + "context" + "fmt" "log" "net/http" "os" @@ -10,6 +12,7 @@ import ( "github.com/rede5/gohorsejobs/backend/docs" "github.com/rede5/gohorsejobs/backend/internal/database" "github.com/rede5/gohorsejobs/backend/internal/router" + "github.com/rede5/gohorsejobs/backend/internal/services" ) // @title GoHorseJobs API @@ -55,6 +58,18 @@ func main() { docs.SwaggerInfo.Host = apiHost docs.SwaggerInfo.Schemes = schemes + // Bootstrap Credentials from Env to DB + // This ensures smooth migration from .env to Database configuration + go func() { + // Initialize temporary credentials service for bootstrapping + credService := services.NewCredentialsService(database.DB) + ctx := context.Background() + if err := credService.BootstrapCredentials(ctx); err != nil { + // Log error but don't crash, it might be transient DB issue + fmt.Printf("Error bootstrapping credentials: %v\n", err) + } + }() + handler := router.NewRouter() port := os.Getenv("BACKEND_PORT") diff --git a/backend/internal/api/handlers/core_handlers.go b/backend/internal/api/handlers/core_handlers.go index 9b1b6f1..6d32338 100644 --- a/backend/internal/api/handlers/core_handlers.go +++ b/backend/internal/api/handlers/core_handlers.go @@ -2,6 +2,7 @@ package handlers import ( "encoding/json" + "fmt" "log" "net" "net/http" @@ -1158,14 +1159,14 @@ func (h *CoreHandlers) SaveFCMToken(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]string{"message": "Token saved successfully"}) } -// SaveCredentials saves encrypted credentials for external services. +// SaveCredentials saves credentials for external services. // @Summary Save Credentials -// @Description Saves encrypted credentials payload (e.g. Stripe key encrypted by Backoffice) +// @Description Saves credentials payload (encrypts them server-side) // @Tags System // @Accept json // @Produce json // @Security BearerAuth -// @Param request body map[string]string true "Credentials Payload" +// @Param request body map[string]interface{} true "Credentials Payload" // @Success 200 {object} map[string]string // @Failure 400 {string} string "Invalid Request" // @Failure 500 {string} string "Internal Server Error" @@ -1179,12 +1180,9 @@ func (h *CoreHandlers) SaveCredentials(w http.ResponseWriter, r *http.Request) { return } - // Double check role is ADMIN or SUPERADMIN just in case middleware missed it (defense in depth) - // But middleware should handle it. - var req struct { - ServiceName string `json:"serviceName"` - EncryptedPayload string `json:"encryptedPayload"` + ServiceName string `json:"serviceName"` + Payload map[string]interface{} `json:"payload"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -1192,12 +1190,27 @@ func (h *CoreHandlers) SaveCredentials(w http.ResponseWriter, r *http.Request) { return } - if req.ServiceName == "" || req.EncryptedPayload == "" { - http.Error(w, "serviceName and encryptedPayload are required", http.StatusBadRequest) + if req.ServiceName == "" || req.Payload == nil { + http.Error(w, "serviceName and payload are required", http.StatusBadRequest) return } - if err := h.credentialsService.SaveCredentials(ctx, req.ServiceName, req.EncryptedPayload, userID); err != nil { + // 1. Marshal payload to JSON string + jsonBytes, err := json.Marshal(req.Payload) + if err != nil { + http.Error(w, "Failed to marshal payload", http.StatusBadRequest) + return + } + + // 2. Encrypt Payload + encryptedPayload, err := h.credentialsService.EncryptPayload(string(jsonBytes)) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to encrypt payload: %v", err), http.StatusInternalServerError) + return + } + + // 3. Save + if err := h.credentialsService.SaveCredentials(ctx, req.ServiceName, encryptedPayload, userID); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/backend/internal/handlers/payment_handler.go b/backend/internal/handlers/payment_handler.go index 3f366e4..66ba263 100644 --- a/backend/internal/handlers/payment_handler.go +++ b/backend/internal/handlers/payment_handler.go @@ -44,6 +44,13 @@ type CreateCheckoutResponse struct { CheckoutURL string `json:"checkoutUrl"` } +// StripeConfig holds the configuration for Stripe +type StripeConfig struct { + SecretKey string `json:"secretKey"` + WebhookSecret string `json:"webhookSecret"` + PublishableKey string `json:"publishableKey"` +} + // CreateCheckout creates a Stripe checkout session for job posting payment // @Summary Create checkout session // @Description Create a Stripe checkout session for job posting payment @@ -68,19 +75,25 @@ func (h *PaymentHandler) CreateCheckout(w http.ResponseWriter, r *http.Request) return } - // Get Stripe secret key from encrypted vault - stripeSecretKey, err := h.credentialsService.GetDecryptedKey(r.Context(), "stripe") - if err != nil { - // Fallback to Env if not found (for migration/dev) - stripeSecretKey = os.Getenv("STRIPE_SECRET_KEY") - if stripeSecretKey == "" { - http.Error(w, "Payment service not configured", http.StatusInternalServerError) - return - } + // Get Stripe config from encrypted vault + var config StripeConfig + payload, err := h.credentialsService.GetDecryptedKey(r.Context(), "stripe") + if err == nil { + json.Unmarshal([]byte(payload), &config) + } + + // Fallback to Env if not found or empty (for migration/dev) + if config.SecretKey == "" { + config.SecretKey = os.Getenv("STRIPE_SECRET_KEY") + } + + if config.SecretKey == "" { + http.Error(w, "Payment service not configured", http.StatusInternalServerError) + return } // Create Stripe checkout session via API - sessionID, checkoutURL, err := createStripeCheckoutSession(stripeSecretKey, req) + sessionID, checkoutURL, err := createStripeCheckoutSession(config.SecretKey, req) if err != nil { http.Error(w, fmt.Sprintf("Failed to create checkout session: %v", err), http.StatusInternalServerError) return @@ -105,8 +118,19 @@ func (h *PaymentHandler) CreateCheckout(w http.ResponseWriter, r *http.Request) // @Failure 400 {string} string "Bad Request" // @Router /api/v1/payments/webhook [post] func (h *PaymentHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { - webhookSecret := os.Getenv("STRIPE_WEBHOOK_SECRET") - if webhookSecret == "" { + // Get Stripe config from encrypted vault + var config StripeConfig + payload, err := h.credentialsService.GetDecryptedKey(r.Context(), "stripe") + if err == nil { + json.Unmarshal([]byte(payload), &config) + } + + // Fallback to Env + if config.WebhookSecret == "" { + config.WebhookSecret = os.Getenv("STRIPE_WEBHOOK_SECRET") + } + + if config.WebhookSecret == "" { http.Error(w, "Webhook secret not configured", http.StatusInternalServerError) return } @@ -120,7 +144,7 @@ func (h *PaymentHandler) HandleWebhook(w http.ResponseWriter, r *http.Request) { // Verify signature signature := r.Header.Get("Stripe-Signature") - if !verifyStripeSignature(body, signature, webhookSecret) { + if !verifyStripeSignature(body, signature, config.WebhookSecret) { http.Error(w, "Invalid signature", http.StatusBadRequest) return } diff --git a/backend/internal/services/cpanel_service.go b/backend/internal/services/cpanel_service.go new file mode 100644 index 0000000..d23b6c3 --- /dev/null +++ b/backend/internal/services/cpanel_service.go @@ -0,0 +1,76 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "net/http" +) + +type CPanelConfig struct { + Host string `json:"host"` + Username string `json:"username"` + APIToken string `json:"apiToken"` +} + +type CPanelService struct { + credentials *CredentialsService +} + +func NewCPanelService(c *CredentialsService) *CPanelService { + return &CPanelService{credentials: c} +} + +// GetConfig returns the decrypted cPanel configuration +func (s *CPanelService) GetConfig(ctx context.Context) (*CPanelConfig, error) { + payload, err := s.credentials.GetDecryptedKey(ctx, "cpanel") + if err != nil { + return nil, fmt.Errorf("cpanel credentials missing: %w", err) + } + + var cfg CPanelConfig + if err := json.Unmarshal([]byte(payload), &cfg); err != nil { + return nil, fmt.Errorf("invalid cpanel config: %w", err) + } + + if cfg.Host == "" || cfg.Username == "" || cfg.APIToken == "" { + return nil, fmt.Errorf("cpanel not configured") + } + + return &cfg, nil +} + +// Example method: ListEmailAccounts (using API Token authentication) +func (s *CPanelService) ListEmailAccounts(ctx context.Context) (map[string]interface{}, error) { + cfg, err := s.GetConfig(ctx) + if err != nil { + return nil, err + } + + url := fmt.Sprintf("%s/execute/Email/list_pops", cfg.Host) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + // cPanel Token Auth Header: Authorization: cpanel : + req.Header.Set("Authorization", fmt.Sprintf("cpanel %s:%s", cfg.Username, cfg.APIToken)) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("cpanel api returned %d", resp.StatusCode) + } + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, err + } + + return result, nil +} diff --git a/backend/internal/services/credentials_service.go b/backend/internal/services/credentials_service.go index 010a5c1..23e0c8a 100644 --- a/backend/internal/services/credentials_service.go +++ b/backend/internal/services/credentials_service.go @@ -8,6 +8,7 @@ import ( "crypto/x509" "database/sql" "encoding/base64" + "encoding/json" "encoding/pem" "fmt" "os" @@ -207,3 +208,130 @@ func (s *CredentialsService) DeleteCredentials(ctx context.Context, serviceName return nil } + +// EncryptPayload encrypts a payload using the derived public key +func (s *CredentialsService) EncryptPayload(payload string) (string, error) { + // 1. Decode Private Key from Env (to derive Public Key) + // In a real scenario, you might store Public Key separately, but we can derive it. + rawPrivateKey, err := base64.StdEncoding.DecodeString(os.Getenv("RSA_PRIVATE_KEY_BASE64")) + if err != nil { + return "", fmt.Errorf("failed to decode env RSA private key: %w", err) + } + + block, _ := pem.Decode(rawPrivateKey) + if block == nil { + return "", fmt.Errorf("failed to parse PEM block containing the private key") + } + + privKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + // Try generic PKCS8 if PKCS1 fails + if key, err2 := x509.ParsePKCS8PrivateKey(block.Bytes); err2 == nil { + if rsaKey, ok := key.(*rsa.PrivateKey); ok { + privKey = rsaKey + } else { + return "", fmt.Errorf("key is not RSA") + } + } else { + return "", err + } + } + + pubKey := &privKey.PublicKey + + // 2. Encrypt using RSA-OAEP + ciphertext, err := rsa.EncryptOAEP( + sha256.New(), + rand.Reader, + pubKey, + []byte(payload), + nil, + ) + if err != nil { + return "", fmt.Errorf("encryption failed: %w", err) + } + + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// BootstrapCredentials checks if credentials are in DB, if not, migrates from Env +func (s *CredentialsService) BootstrapCredentials(ctx context.Context) error { + // List of services and their env mapping + services := map[string]func() interface{}{ + "stripe": func() interface{} { + return map[string]string{ + "secretKey": os.Getenv("STRIPE_SECRET_KEY"), + "webhookSecret": os.Getenv("STRIPE_WEBHOOK_SECRET"), + "publishableKey": os.Getenv("STRIPE_PUBLISHABLE_KEY"), + } + }, + "storage": func() interface{} { + return map[string]string{ + "endpoint": os.Getenv("AWS_ENDPOINT"), + "accessKey": os.Getenv("AWS_ACCESS_KEY_ID"), + "secretKey": os.Getenv("AWS_SECRET_ACCESS_KEY"), + "bucket": os.Getenv("S3_BUCKET"), + "region": os.Getenv("AWS_REGION"), + } + }, + "cloudflare_config": func() interface{} { + return map[string]string{ + "apiToken": os.Getenv("CLOUDFLARE_API_TOKEN"), + "zoneId": os.Getenv("CLOUDFLARE_ZONE_ID"), + } + }, + "cpanel": func() interface{} { + return map[string]string{ + "host": os.Getenv("CPANEL_HOST"), + "username": os.Getenv("CPANEL_USERNAME"), + "apiToken": os.Getenv("CPANEL_API_TOKEN"), + } + }, + } + + for service, getEnvData := range services { + // Check if already configured + configured, err := s.isServiceConfigured(ctx, service) + if err != nil { + fmt.Printf("[CredentialsBootstrap] Error checking %s: %v\n", service, err) + continue + } + + if !configured { + data := getEnvData() + // Validate if env vars exist (naive check: at least one field not empty) + hasData := false + jsonBytes, _ := json.Marshal(data) + var stringMap map[string]string + json.Unmarshal(jsonBytes, &stringMap) + for _, v := range stringMap { + if v != "" { + hasData = true + break + } + } + + if hasData { + fmt.Printf("[CredentialsBootstrap] Migrating %s from Env to DB...\n", service) + encrypted, err := s.EncryptPayload(string(jsonBytes)) + if err != nil { + fmt.Printf("[CredentialsBootstrap] Failed to encrypt %s: %v\n", service, err) + continue + } + if err := s.SaveCredentials(ctx, service, encrypted, "system_bootstrap"); err != nil { + fmt.Printf("[CredentialsBootstrap] Failed to save %s: %v\n", service, err) + } else { + fmt.Printf("[CredentialsBootstrap] Successfully migrated %s\n", service) + } + } + } + } + return nil +} + +func (s *CredentialsService) isServiceConfigured(ctx context.Context, serviceName string) (bool, error) { + var exists bool + query := `SELECT EXISTS(SELECT 1 FROM external_services_credentials WHERE service_name = $1)` + err := s.DB.QueryRowContext(ctx, query, serviceName).Scan(&exists) + return exists, err +} diff --git a/frontend/src/app/dashboard/credentials/page.tsx b/frontend/src/app/dashboard/credentials/page.tsx new file mode 100644 index 0000000..6bfd111 --- /dev/null +++ b/frontend/src/app/dashboard/credentials/page.tsx @@ -0,0 +1,245 @@ +"use client" + +import { useEffect, useState } from "react" +import { toast } from "sonner" +import { credentialsApi, ConfiguredService } from "@/lib/api" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { Check, Loader2, Plus, Shield, Trash2, X } from "lucide-react" + +export default function CredentialsPage() { + const [loading, setLoading] = useState(true) + const [services, setServices] = useState([]) + const [openDialog, setOpenDialog] = useState(false) + const [selectedService, setSelectedService] = useState("") + const [formData, setFormData] = useState>({}) + const [saving, setSaving] = useState(false) + + // Predefined schemas for known services + const schemas: Record = { + stripe: { + label: "Stripe", + fields: [ + { key: "secretKey", label: "Secret Key (sk_...)", type: "password" }, + { key: "webhookSecret", label: "Webhook Secret (whsec_...)", type: "password" }, + { key: "publishableKey", label: "Publishable Key (pk_...)", type: "text" }, + ] + }, + storage: { + label: "AWS S3 / Compatible", + fields: [ + { key: "endpoint", label: "Endpoint URL", type: "text" }, + { key: "region", label: "Region", type: "text" }, + { key: "bucket", label: "Bucket Name", type: "text" }, + { key: "accessKey", label: "Access Key ID", type: "text" }, + { key: "secretKey", label: "Secret Access Key", type: "password" }, + ] + }, + cpanel: { + label: "cPanel Integration", + fields: [ + { key: "host", label: "cPanel URL (https://domain:2083)", type: "text" }, + { key: "username", label: "Username", type: "text" }, + { key: "apiToken", label: "API Token", type: "password" }, + ] + }, + cloudflare_config: { + label: "Cloudflare", + fields: [ + { key: "apiToken", label: "API Token", type: "password" }, + { key: "zoneId", label: "Zone ID", type: "text" }, + ] + }, + smtp: { + label: "SMTP Email", + fields: [ + { key: "host", label: "Host", type: "text" }, + { key: "port", label: "Port", type: "number" }, + { key: "username", label: "Username", type: "text" }, + { key: "password", label: "Password", type: "password" }, + { key: "from_email", label: "From Email", type: "email" }, + { key: "from_name", label: "From Name", type: "text" }, + { key: "secure", label: "Use TLS", type: "checkbox" } // TODO handle checkbox + ] + }, + appwrite: { + label: "Appwrite", + fields: [ + { key: "endpoint", label: "Endpoint", type: "text" }, + { key: "projectId", label: "Project ID", type: "text" }, + { key: "apiKey", label: "API Key", type: "password" }, + ] + }, + firebase: { + label: "Firebase (JSON)", + fields: [ + { key: "serviceAccountJson", label: "Service Account JSON Content", type: "textarea" } + ] + } + } + + const availableServices = Object.keys(schemas) + + useEffect(() => { + loadServices() + }, []) + + const loadServices = async () => { + try { + setLoading(true) + const res = await credentialsApi.list() + // Backend returns { services: [...] } + if (res && res.services) { + setServices(res.services) + } + } catch (error) { + toast.error("Failed to load credentials") + console.error(error) + } finally { + setLoading(false) + } + } + + const handleEdit = (serviceName: string) => { + setSelectedService(serviceName) + setFormData({}) // Reset form, we don't load existing secrets for security + setOpenDialog(true) + } + + const handleSave = async () => { + if (!selectedService) return + + try { + setSaving(true) + await credentialsApi.save(selectedService, formData) + toast.success(`${schemas[selectedService]?.label || selectedService} credentials saved!`) + setOpenDialog(false) + loadServices() + } catch (error: any) { + toast.error(error.message || "Failed to save") + } finally { + setSaving(false) + } + } + + const handleDelete = async (serviceName: string) => { + if (!confirm(`Are you sure you want to delete credentials for ${serviceName}? This will break functionality relying on it.`)) return + + try { + await credentialsApi.delete(serviceName) + toast.success("Credentials deleted") + loadServices() + } catch (error: any) { + toast.error("Failed to delete") + } + } + + return ( +
+
+
+

System Credentials

+

+ Manage external service connections securely. Keys are encrypted in the database. +

+
+
+ +
+ {services.map((svc) => ( + + + + {schemas[svc.service_name]?.label || svc.service_name} + + {svc.is_configured ? ( + + Active + + ) : ( + + Pending + + )} + + +
+ +
+

+ {svc.is_configured + ? `Last updated ${new Date(svc.updated_at).toLocaleDateString()}` + : "Not configured yet"} +

+
+ + {svc.is_configured && ( + + )} +
+
+
+ ))} +
+ + + + + Configure {schemas[selectedService]?.label || selectedService} + + Enter the credentials for this service. They will be encrypted before storage. + + + +
+ {schemas[selectedService]?.fields.map((field) => ( +
+ + {field.type === 'textarea' ? ( +