feat: add backoffice credentials page and backend support

This commit is contained in:
Tiago Yamamoto 2025-12-31 16:13:27 -03:00
parent e637117f40
commit 2e7da0b28e
8 changed files with 532 additions and 26 deletions

View file

@ -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")

View file

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

View file

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

View file

@ -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 <username>:<token>
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
}

View file

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

View file

@ -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<ConfiguredService[]>([])
const [openDialog, setOpenDialog] = useState(false)
const [selectedService, setSelectedService] = useState<string>("")
const [formData, setFormData] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
// Predefined schemas for known services
const schemas: Record<string, { label: string, fields: { key: string, label: string, type?: string }[] }> = {
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 (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">System Credentials</h1>
<p className="text-muted-foreground mt-2">
Manage external service connections securely. Keys are encrypted in the database.
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{services.map((svc) => (
<Card key={svc.service_name} className={svc.is_configured ? "border-green-500/50" : "opacity-70"}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{schemas[svc.service_name]?.label || svc.service_name}
</CardTitle>
{svc.is_configured ? (
<Badge variant="default" className="bg-green-500/15 text-green-700 hover:bg-green-500/25 border-green-500/50">
<Check className="h-3 w-3 mr-1" /> Active
</Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
Pending
</Badge>
)}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold pt-2">
<Shield className={svc.is_configured ? "text-green-500" : "text-gray-400"} size={32} />
</div>
<p className="text-xs text-muted-foreground mt-4">
{svc.is_configured
? `Last updated ${new Date(svc.updated_at).toLocaleDateString()}`
: "Not configured yet"}
</p>
<div className="mt-4 flex gap-2">
<Button variant="outline" size="sm" className="w-full" onClick={() => handleEdit(svc.service_name)}>
{svc.is_configured ? "Update" : "Configure"}
</Button>
{svc.is_configured && (
<Button variant="ghost" size="icon" className="text-destructive hover:bg-destructive/10" onClick={() => handleDelete(svc.service_name)}>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
<Dialog open={openDialog} onOpenChange={setOpenDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Configure {schemas[selectedService]?.label || selectedService}</DialogTitle>
<DialogDescription>
Enter the credentials for this service. They will be encrypted before storage.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{schemas[selectedService]?.fields.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={field.key}>{field.label}</Label>
{field.type === 'textarea' ? (
<textarea
id={field.key}
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={formData[field.key] || ""}
onChange={(e) => setFormData({ ...formData, [field.key]: e.target.value })}
/>
) : (
<Input
id={field.key}
type={field.type}
value={formData[field.key] || ""}
onChange={(e) => setFormData({ ...formData, [field.key]: e.target.value })}
placeholder={`Enter ${field.label}`}
/>
)}
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpenDialog(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Credentials
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View file

@ -4,7 +4,7 @@ import Link from "next/link"
import Image from "next/image"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import { LayoutDashboard, Briefcase, Users, MessageSquare, Building2, FileText, HelpCircle, Ticket } from "lucide-react"
import { LayoutDashboard, Briefcase, Users, MessageSquare, Building2, FileText, HelpCircle, Ticket, Shield } from "lucide-react"
import { getCurrentUser, isAdminUser } from "@/lib/auth"
import { useTranslation } from "@/lib/i18n"
@ -55,6 +55,11 @@ const Sidebar = () => {
href: "/dashboard/tickets",
icon: Ticket,
},
{
title: "Credentials",
href: "/dashboard/credentials",
icon: Shield,
},
]
const companyItems = [

View file

@ -709,7 +709,7 @@ export interface ConfiguredService {
export const credentialsApi = {
list: () => apiRequest<{ services: ConfiguredService[] }>("/api/v1/system/credentials"),
save: (serviceName: string, payload: string) => apiRequest<void>("/api/v1/system/credentials", {
save: (serviceName: string, payload: any) => apiRequest<void>("/api/v1/system/credentials", {
method: "POST",
body: JSON.stringify({ serviceName, payload }),
}),