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.
This commit is contained in:
parent
7d797aac2b
commit
e5d0cd483a
4 changed files with 339 additions and 58 deletions
|
|
@ -227,8 +227,31 @@ func NewRouter() http.Handler {
|
||||||
// Storage (Presigned URL)
|
// Storage (Presigned URL)
|
||||||
mux.Handle("GET /api/v1/storage/upload-url", authMiddleware.HeaderAuthGuard(http.HandlerFunc(storageHandler.GetUploadURL)))
|
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("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))))
|
mux.Handle("POST /api/v1/system/cloudflare/purge", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.PurgeCache))))
|
||||||
|
|
||||||
// Email Templates & Settings (Admin Only)
|
// Email Templates & Settings (Admin Only)
|
||||||
|
|
|
||||||
|
|
@ -133,3 +133,77 @@ func (s *CredentialsService) decryptPayload(encryptedPayload string) (string, er
|
||||||
|
|
||||||
return string(plaintext), nil
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,21 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { settingsApi } from "@/lib/api"
|
import { settingsApi, credentialsApi, ConfiguredService } from "@/lib/api"
|
||||||
import { toast } from "sonner"
|
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 {
|
interface ThemeConfig {
|
||||||
logoUrl: string
|
logoUrl: string
|
||||||
|
|
@ -27,31 +39,53 @@ export default function SettingsPage() {
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
// Credentials State
|
||||||
|
const [credentials, setCredentials] = useState<ConfiguredService[]>([])
|
||||||
|
const [loadingCredentials, setLoadingCredentials] = useState(false)
|
||||||
|
const [selectedService, setSelectedService] = useState<string | null>(null)
|
||||||
|
const [credentialPayload, setCredentialPayload] = useState("")
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
|
||||||
const fetchSettings = async () => {
|
const fetchSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await settingsApi.get("theme")
|
const data = await settingsApi.get("theme")
|
||||||
if (data && Object.keys(data).length > 0) {
|
if (data && Object.keys(data).length > 0) {
|
||||||
setConfig({ ...DEFAULT_THEME, ...data }) // Merge with defaults
|
setConfig({ ...DEFAULT_THEME, ...data })
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch theme settings", 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 {
|
} finally {
|
||||||
setLoading(false)
|
setLoadingCredentials(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSettings()
|
Promise.all([fetchSettings(), fetchCredentials()]).finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSaveTheme = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
await settingsApi.save("theme", config)
|
await settingsApi.save("theme", config)
|
||||||
toast.success("Theme settings saved successfully")
|
toast.success("Theme settings saved")
|
||||||
// Force reload to apply? Or use Context.
|
|
||||||
// Ideally Context updates. For now, reload works.
|
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save settings", 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) {
|
if (loading) {
|
||||||
return <div className="flex justify-center p-8"><Loader2 className="animate-spin" /></div>
|
return <div className="flex justify-center p-8"><Loader2 className="animate-spin" /></div>
|
||||||
}
|
}
|
||||||
|
|
@ -69,65 +140,159 @@ export default function SettingsPage() {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">System Settings</h1>
|
<h1 className="text-3xl font-bold tracking-tight">System Settings</h1>
|
||||||
<p className="text-muted-foreground">Manage application appearance and configuration.</p>
|
<p className="text-muted-foreground">Manage application appearance and integrations.</p>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="grid gap-6">
|
<Tabs defaultValue="theme" className="space-y-4">
|
||||||
<Card>
|
<TabsList>
|
||||||
<CardHeader>
|
<TabsTrigger value="theme">Branding & Theme</TabsTrigger>
|
||||||
<CardTitle>Branding & Theme</CardTitle>
|
<TabsTrigger value="integrations">Integrations & Credentials</TabsTrigger>
|
||||||
<CardDescription>Customize the look and feel of your dashboard.</CardDescription>
|
</TabsList>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor="companyName">Company Name</Label>
|
|
||||||
<Input
|
|
||||||
id="companyName"
|
|
||||||
value={config.companyName}
|
|
||||||
onChange={(e) => setConfig({ ...config, companyName: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<TabsContent value="theme" className="space-y-4">
|
||||||
<Label htmlFor="logoUrl">Logo URL</Label>
|
<Card>
|
||||||
<div className="flex gap-4 items-center">
|
<CardHeader>
|
||||||
|
<CardTitle>Branding & Theme</CardTitle>
|
||||||
|
<CardDescription>Customize the look and feel of your dashboard.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="companyName">Company Name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="logoUrl"
|
id="companyName"
|
||||||
value={config.logoUrl}
|
value={config.companyName}
|
||||||
onChange={(e) => setConfig({ ...config, logoUrl: e.target.value })}
|
onChange={(e) => setConfig({ ...config, companyName: e.target.value })}
|
||||||
/>
|
/>
|
||||||
{config.logoUrl && (
|
|
||||||
<img src={config.logoUrl} alt="Preview" className="h-10 w-auto border rounded bg-muted p-1" onError={(e) => e.currentTarget.style.display = 'none'} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">Enter a public URL for your logo.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="primaryColor">Primary Color</Label>
|
<Label htmlFor="logoUrl">Logo URL</Label>
|
||||||
<div className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center">
|
||||||
<Input
|
<Input
|
||||||
id="primaryColor"
|
id="logoUrl"
|
||||||
type="color"
|
value={config.logoUrl}
|
||||||
className="w-20 h-10 p-1 cursor-pointer"
|
onChange={(e) => setConfig({ ...config, logoUrl: e.target.value })}
|
||||||
value={config.primaryColor}
|
/>
|
||||||
onChange={(e) => setConfig({ ...config, primaryColor: e.target.value })}
|
{config.logoUrl && (
|
||||||
/>
|
<div className="h-10 w-10 border rounded bg-muted flex items-center justify-center overflow-hidden">
|
||||||
<div className="flex-1 p-2 rounded text-white text-center text-sm" style={{ backgroundColor: config.primaryColor }}>
|
<img src={config.logoUrl} alt="Preview" className="max-h-full max-w-full" onError={(e) => e.currentTarget.style.display = 'none'} />
|
||||||
Sample Button
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Enter a public URL for your logo.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="primaryColor">Primary Color</Label>
|
||||||
|
<div className="flex gap-4 items-center">
|
||||||
|
<Input
|
||||||
|
id="primaryColor"
|
||||||
|
type="color"
|
||||||
|
className="w-20 h-10 p-1 cursor-pointer"
|
||||||
|
value={config.primaryColor}
|
||||||
|
onChange={(e) => setConfig({ ...config, primaryColor: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 p-2 rounded text-white text-center text-sm" style={{ backgroundColor: config.primaryColor }}>
|
||||||
|
Sample Button
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<div className="p-6 pt-0 flex justify-end">
|
||||||
|
<Button onClick={handleSaveTheme} disabled={saving}>
|
||||||
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="integrations" className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>External Services</CardTitle>
|
||||||
|
<CardDescription>Manage credentials for third-party integrations securely.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{credentials.map((service) => (
|
||||||
|
<div key={service.service_name} className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium capitalize">{service.service_name}</p>
|
||||||
|
{service.is_configured ? (
|
||||||
|
<Badge variant="default" className="bg-green-600 hover:bg-green-700">
|
||||||
|
<Check className="w-3 h-3 mr-1" /> Configured
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">Not Configured</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{service.is_configured
|
||||||
|
? `Updated on ${new Date(service.updated_at).toLocaleDateString()} by ${service.updated_by || 'Unknown'}`
|
||||||
|
: 'No credentials saved for this service.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => handleOpenCredentialDialog(service.service_name)}>
|
||||||
|
<Key className="w-4 h-4 mr-2" />
|
||||||
|
{service.is_configured ? "Update" : "Setup"}
|
||||||
|
</Button>
|
||||||
|
{service.is_configured && (
|
||||||
|
<Button variant="destructive" size="sm" onClick={() => handleDeleteCredential(service.service_name)}>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Configure {selectedService}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter the secret credentials for {selectedService}. These will be encrypted and stored securely.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="payload">Secret Payload (JSON or Token)</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Textarea
|
||||||
|
id="payload"
|
||||||
|
value={credentialPayload}
|
||||||
|
onChange={(e) => setCredentialPayload(e.target.value)}
|
||||||
|
placeholder={selectedService === 'firebase' ? 'Paste service-account.json content here...' : 'Paste API Key or Connection String...'}
|
||||||
|
className="min-h-[100px] font-mono text-xs pr-10" // Monospace for keys
|
||||||
|
// Hack to masking if needed? No real way to mask textarea easily.
|
||||||
|
// But typically service account JSONs are visible when pasting.
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{selectedService === 'firebase' && "Paste the entire content of your service-account.json"}
|
||||||
|
{selectedService === 'stripe' && "Paste your Stripe Secret Key (sk_...)"}
|
||||||
|
{selectedService === 'appwrite' && "Paste your Appwrite API Key"}
|
||||||
|
{selectedService === 'lavinmq' && "Paste your AMQP URL"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
<div className="p-6 pt-0 flex justify-end">
|
|
||||||
<Button onClick={handleSave} disabled={saving}>
|
|
||||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
<DialogFooter>
|
||||||
</div>
|
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleSaveCredential} disabled={saving}>
|
||||||
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Save Credentials
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -680,6 +680,25 @@ export const settingsApi = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- System Credentials ---
|
||||||
|
export interface ConfiguredService {
|
||||||
|
service_name: string;
|
||||||
|
updated_at: string;
|
||||||
|
updated_by: string;
|
||||||
|
is_configured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const credentialsApi = {
|
||||||
|
list: () => apiRequest<{ services: ConfiguredService[] }>("/api/v1/system/credentials"),
|
||||||
|
save: (serviceName: string, payload: string) => apiRequest<void>("/api/v1/system/credentials", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ serviceName, payload }),
|
||||||
|
}),
|
||||||
|
delete: (serviceName: string) => apiRequest<void>(`/api/v1/system/credentials/${serviceName}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
// --- Email Templates & Settings ---
|
// --- Email Templates & Settings ---
|
||||||
export interface EmailTemplate {
|
export interface EmailTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue