feat: add backoffice credentials page and backend support
This commit is contained in:
parent
e637117f40
commit
2e7da0b28e
8 changed files with 532 additions and 26 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
76
backend/internal/services/cpanel_service.go
Normal file
76
backend/internal/services/cpanel_service.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
245
frontend/src/app/dashboard/credentials/page.tsx
Normal file
245
frontend/src/app/dashboard/credentials/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}),
|
||||
|
|
|
|||
Loading…
Reference in a new issue