From e5d0cd483afe056075633f0f45c3953e0ad1a919 Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Fri, 26 Dec 2025 14:43:35 -0300 Subject: [PATCH] feat: Implement Dynamic Credentials Management Backend: - Added GET /api/v1/system/credentials to list configured services - Added DELETE /api/v1/system/credentials/{service} - Updated CredentialsService to support listing without revealing secrets Frontend: - Updated Settings page with Tabs - Added 'Integrations' tab to manage credentials - Allows Configuring (Update) and Deleting credentials - Lists status of Appwrite, Stripe, Firebase, etc. --- backend/internal/router/router.go | 25 +- .../internal/services/credentials_service.go | 74 +++++ frontend/src/app/dashboard/settings/page.tsx | 279 ++++++++++++++---- frontend/src/lib/api.ts | 19 ++ 4 files changed, 339 insertions(+), 58 deletions(-) diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 315fa70..05044bc 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -227,8 +227,31 @@ func NewRouter() http.Handler { // Storage (Presigned URL) mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.HeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL))) - // System Credentials Route + // System Credentials Routes mux.Handle("POST /api/v1/system/credentials", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(coreHandlers.SaveCredentials)))) + mux.Handle("GET /api/v1/system/credentials", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + services, err := credentialsService.ListConfiguredServices(r.Context()) + if err != nil { + http.Error(w, `{"error": "Failed to list credentials"}`, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"services": services}) + })))) + mux.Handle("DELETE /api/v1/system/credentials/{service}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + service := r.PathValue("service") + if service == "" { + http.Error(w, `{"error": "Service name required"}`, http.StatusBadRequest) + return + } + err := credentialsService.DeleteCredentials(r.Context(), service) + if err != nil { + http.Error(w, `{"error": "Failed to delete credentials"}`, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"success": true}) + })))) mux.Handle("POST /api/v1/system/cloudflare/purge", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.PurgeCache)))) // Email Templates & Settings (Admin Only) diff --git a/backend/internal/services/credentials_service.go b/backend/internal/services/credentials_service.go index b8af811..010a5c1 100644 --- a/backend/internal/services/credentials_service.go +++ b/backend/internal/services/credentials_service.go @@ -133,3 +133,77 @@ func (s *CredentialsService) decryptPayload(encryptedPayload string) (string, er return string(plaintext), nil } + +// ConfiguredService represents a service with saved credentials (without revealing the actual value) +type ConfiguredService struct { + ServiceName string `json:"service_name"` + UpdatedAt string `json:"updated_at"` + UpdatedBy string `json:"updated_by,omitempty"` + IsConfigured bool `json:"is_configured"` +} + +// ListConfiguredServices returns all configured services without revealing credential values +func (s *CredentialsService) ListConfiguredServices(ctx context.Context) ([]ConfiguredService, error) { + // Define all supported services + allServices := []string{ + "appwrite", + "stripe", + "firebase", + "cloudflare", + "smtp", + "s3", + "lavinmq", + } + + query := ` + SELECT service_name, updated_at, COALESCE(updated_by::text, '') as updated_by + FROM external_services_credentials + ` + rows, err := s.DB.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + // Map of configured services + configured := make(map[string]ConfiguredService) + for rows.Next() { + var cs ConfiguredService + if err := rows.Scan(&cs.ServiceName, &cs.UpdatedAt, &cs.UpdatedBy); err != nil { + return nil, err + } + cs.IsConfigured = true + configured[cs.ServiceName] = cs + } + + // Build result with all services + result := make([]ConfiguredService, 0, len(allServices)) + for _, name := range allServices { + if cs, ok := configured[name]; ok { + result = append(result, cs) + } else { + result = append(result, ConfiguredService{ + ServiceName: name, + IsConfigured: false, + }) + } + } + + return result, nil +} + +// DeleteCredentials removes credentials for a service +func (s *CredentialsService) DeleteCredentials(ctx context.Context, serviceName string) error { + query := `DELETE FROM external_services_credentials WHERE service_name = $1` + _, err := s.DB.ExecContext(ctx, query, serviceName) + if err != nil { + return err + } + + // Clear cache + s.cacheMutex.Lock() + delete(s.cache, serviceName) + s.cacheMutex.Unlock() + + return nil +} diff --git a/frontend/src/app/dashboard/settings/page.tsx b/frontend/src/app/dashboard/settings/page.tsx index 96ce326..bafb3dd 100644 --- a/frontend/src/app/dashboard/settings/page.tsx +++ b/frontend/src/app/dashboard/settings/page.tsx @@ -6,9 +6,21 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { Separator } from "@/components/ui/separator" -import { settingsApi } from "@/lib/api" +import { settingsApi, credentialsApi, ConfiguredService } from "@/lib/api" import { toast } from "sonner" -import { Loader2, Check } from "lucide-react" +import { Loader2, Check, Key, Trash2, Eye, EyeOff } from "lucide-react" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { Textarea } from "@/components/ui/textarea" interface ThemeConfig { logoUrl: string @@ -27,31 +39,53 @@ export default function SettingsPage() { const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) + // Credentials State + const [credentials, setCredentials] = useState([]) + const [loadingCredentials, setLoadingCredentials] = useState(false) + const [selectedService, setSelectedService] = useState(null) + const [credentialPayload, setCredentialPayload] = useState("") + const [isDialogOpen, setIsDialogOpen] = useState(false) + const [showPassword, setShowPassword] = useState(false) + const fetchSettings = async () => { try { const data = await settingsApi.get("theme") if (data && Object.keys(data).length > 0) { - setConfig({ ...DEFAULT_THEME, ...data }) // Merge with defaults + setConfig({ ...DEFAULT_THEME, ...data }) } } catch (error) { console.error("Failed to fetch theme settings", error) - // Accept default + } + } + + const fetchCredentials = async () => { + setLoadingCredentials(true) + try { + const res = await credentialsApi.list() + // Ensure we handle the response correctly (api.ts wraps it in { services: ... }) + if (res && res.services) { + setCredentials(res.services) + } else if (Array.isArray(res)) { + // Fallback if API returns array directly + setCredentials(res) + } + } catch (error) { + console.error("Failed to fetch credentials", error) + toast.error("Failed to load credentials status") } finally { - setLoading(false) + setLoadingCredentials(false) } } useEffect(() => { - fetchSettings() + Promise.all([fetchSettings(), fetchCredentials()]).finally(() => setLoading(false)) }, []) - const handleSave = async () => { + const handleSaveTheme = async () => { setSaving(true) try { await settingsApi.save("theme", config) - toast.success("Theme settings saved successfully") - // Force reload to apply? Or use Context. - // Ideally Context updates. For now, reload works. + toast.success("Theme settings saved") window.location.reload() } catch (error) { console.error("Failed to save settings", error) @@ -61,6 +95,43 @@ export default function SettingsPage() { } } + const handleOpenCredentialDialog = (serviceName: string) => { + setSelectedService(serviceName) + setCredentialPayload("") + setShowPassword(false) + setIsDialogOpen(true) + } + + const handleSaveCredential = async () => { + if (!selectedService || !credentialPayload) return + + setSaving(true) + try { + await credentialsApi.save(selectedService, credentialPayload) + toast.success(`Credentials for ${selectedService} saved`) + setIsDialogOpen(false) + fetchCredentials() // Refresh list + } catch (error) { + console.error("Failed to save credential", error) + toast.error("Failed to save credential") + } finally { + setSaving(false) + } + } + + const handleDeleteCredential = async (serviceName: string) => { + if (!confirm(`Are you sure you want to remove credentials for ${serviceName}?`)) return + + try { + await credentialsApi.delete(serviceName) + toast.success(`Credentials for ${serviceName} removed`) + fetchCredentials() + } catch (error) { + console.error("Failed to delete credential", error) + toast.error("Failed to delete credential") + } + } + if (loading) { return
} @@ -69,65 +140,159 @@ export default function SettingsPage() {

System Settings

-

Manage application appearance and configuration.

+

Manage application appearance and integrations.

-
- - - Branding & Theme - Customize the look and feel of your dashboard. - - -
- - setConfig({ ...config, companyName: e.target.value })} - /> -
+ + + Branding & Theme + Integrations & Credentials + -
- -
+ + + + Branding & Theme + Customize the look and feel of your dashboard. + + +
+ setConfig({ ...config, logoUrl: e.target.value })} + id="companyName" + value={config.companyName} + onChange={(e) => setConfig({ ...config, companyName: e.target.value })} /> - {config.logoUrl && ( - Preview e.currentTarget.style.display = 'none'} /> - )}
-

Enter a public URL for your logo.

-
-
- -
- setConfig({ ...config, primaryColor: e.target.value })} - /> -
- Sample Button +
+ +
+ setConfig({ ...config, logoUrl: e.target.value })} + /> + {config.logoUrl && ( +
+ Preview e.currentTarget.style.display = 'none'} /> +
+ )} +
+

Enter a public URL for your logo.

+
+ +
+ +
+ setConfig({ ...config, primaryColor: e.target.value })} + /> +
+ Sample Button +
+ +
+ +
+ + + + + + + External Services + Manage credentials for third-party integrations securely. + + +
+ {credentials.map((service) => ( +
+
+
+

{service.service_name}

+ {service.is_configured ? ( + + Configured + + ) : ( + Not Configured + )} +
+

+ {service.is_configured + ? `Updated on ${new Date(service.updated_at).toLocaleDateString()} by ${service.updated_by || 'Unknown'}` + : 'No credentials saved for this service.'} +

+
+
+ + {service.is_configured && ( + + )} +
+
+ ))} +
+
+
+
+ + + + + + Configure {selectedService} + + Enter the secret credentials for {selectedService}. These will be encrypted and stored securely. + + +
+
+ +
+