feat: infrastructure updates, storage verification, and superadmin reset
1. Auth: Implemented forced password reset for SuperAdmin and updated login logic. 2. Infra: Switched backend to internal Postgres and updated .drone.yml. 3. Storage: Added Test Connection endpoint and UI in Backoffice. 4. CI/CD: Updated Forgejo deploy pipeline to include Seeder and use Internal Registry.
This commit is contained in:
parent
b5e9ef60ef
commit
3cd52accfb
11 changed files with 243 additions and 28 deletions
|
|
@ -10,11 +10,11 @@ on:
|
||||||
- 'frontend/**'
|
- 'frontend/**'
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: rg.fr-par.scw.cloud/funcscwinfrastructureascodehdz4uzhb
|
REGISTRY: in.gohorsejobs.com
|
||||||
NAMESPACE: a5034510-9763-40e8-ac7e-1836e7a61460
|
NAMESPACE: gohorsejobsdev
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Job: Deploy no Servidor (Pull das imagens do Scaleway)
|
# Job: Deploy no Servidor (Pull das imagens do Forgejo)
|
||||||
deploy-dev:
|
deploy-dev:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
steps:
|
steps:
|
||||||
|
|
@ -41,6 +41,11 @@ jobs:
|
||||||
else
|
else
|
||||||
echo "backoffice=false" >> $GITHUB_OUTPUT
|
echo "backoffice=false" >> $GITHUB_OUTPUT
|
||||||
fi
|
fi
|
||||||
|
if git diff --name-only HEAD~1 HEAD | grep -q "^seeder-api/"; then
|
||||||
|
echo "seeder=true" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "seeder=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Deploy via SSH
|
- name: Deploy via SSH
|
||||||
uses: https://github.com/appleboy/ssh-action@v1.0.3
|
uses: https://github.com/appleboy/ssh-action@v1.0.3
|
||||||
|
|
@ -50,33 +55,45 @@ jobs:
|
||||||
key: ${{ secrets.SSH_KEY }}
|
key: ${{ secrets.SSH_KEY }}
|
||||||
port: ${{ secrets.PORT || 22 }}
|
port: ${{ secrets.PORT || 22 }}
|
||||||
script: |
|
script: |
|
||||||
# Login no Scaleway Registry
|
# Login no Forgejo Registry (usando segredos do Drone/Forgejo)
|
||||||
echo "${{ secrets.SCW_SECRET_KEY }}" | podman login ${{ env.REGISTRY }} -u nologin --password-stdin
|
echo "${{ secrets.HARBOR_PASSWORD }}" | podman login ${{ env.REGISTRY }} -u ${{ secrets.HARBOR_USERNAME }} --password-stdin
|
||||||
|
|
||||||
# --- DEPLOY DO BACKEND ---
|
# --- DEPLOY DO BACKEND ---
|
||||||
if [ "${{ steps.check.outputs.backend }}" == "true" ]; then
|
if [ "${{ steps.check.outputs.backend }}" == "true" ]; then
|
||||||
echo "Pulling e reiniciando Backend..."
|
echo "Pulling e reiniciando Backend..."
|
||||||
podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/backend:dev-latest
|
# Nome da imagem no Drone: gohorsejobs-backend
|
||||||
podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/backend:dev-latest localhost/gohorsejobs-backend-dev:latest
|
podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/gohorsejobs-backend:latest
|
||||||
|
podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/gohorsejobs-backend:latest localhost/gohorsejobs-backend-dev:latest
|
||||||
sudo systemctl restart gohorsejobs-backend-dev
|
sudo systemctl restart gohorsejobs-backend-dev
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- DEPLOY DO FRONTEND ---
|
# --- DEPLOY DO FRONTEND ---
|
||||||
if [ "${{ steps.check.outputs.frontend }}" == "true" ]; then
|
if [ "${{ steps.check.outputs.frontend }}" == "true" ]; then
|
||||||
echo "Pulling e reiniciando Frontend..."
|
echo "Pulling e reiniciando Frontend..."
|
||||||
podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/frontend:dev-latest
|
# Assumindo gohorsejobs-frontend no mesmo namespace
|
||||||
podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/frontend:dev-latest localhost/gohorsejobs-frontend-dev:latest
|
podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/gohorsejobs-frontend:latest
|
||||||
|
podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/gohorsejobs-frontend:latest localhost/gohorsejobs-frontend-dev:latest
|
||||||
sudo systemctl restart gohorsejobs-frontend-dev
|
sudo systemctl restart gohorsejobs-frontend-dev
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- DEPLOY DO BACKOFFICE ---
|
# --- DEPLOY DO BACKOFFICE ---
|
||||||
if [ "${{ steps.check.outputs.backoffice }}" == "true" ]; then
|
if [ "${{ steps.check.outputs.backoffice }}" == "true" ]; then
|
||||||
echo "Pulling e reiniciando Backoffice..."
|
echo "Pulling e reiniciando Backoffice..."
|
||||||
podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/backoffice:dev-latest
|
# Nome no Drone: backoffice
|
||||||
podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/backoffice:dev-latest localhost/gohorsejobs-backoffice-dev:latest
|
podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/backoffice:latest
|
||||||
|
podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/backoffice:latest localhost/gohorsejobs-backoffice-dev:latest
|
||||||
sudo systemctl restart gohorsejobs-backoffice-dev
|
sudo systemctl restart gohorsejobs-backoffice-dev
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- DEPLOY DO SEEDER ---
|
||||||
|
if [ "${{ steps.check.outputs.seeder }}" == "true" ]; then
|
||||||
|
echo "Pulling e reiniciando Seeder..."
|
||||||
|
# Assumindo gohorsejobs-seeder
|
||||||
|
podman pull ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/gohorsejobs-seeder:latest
|
||||||
|
podman tag ${{ env.REGISTRY }}/${{ env.NAMESPACE }}/gohorsejobs-seeder:latest localhost/gohorsejobs-seeder-dev:latest
|
||||||
|
sudo systemctl restart gohorsejobs-seeder-dev
|
||||||
|
fi
|
||||||
|
|
||||||
# --- LIMPEZA ---
|
# --- LIMPEZA ---
|
||||||
echo "Limpando imagens antigas..."
|
echo "Limpando imagens antigas..."
|
||||||
podman image prune -f || true
|
podman image prune -f || true
|
||||||
48
backend/internal/api/controllers/storage_controller.go
Normal file
48
backend/internal/api/controllers/storage_controller.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/rede5/gohorsejobs/backend/internal/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StorageController struct {
|
||||||
|
storageService *services.StorageService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStorageController(storageService *services.StorageService) *StorageController {
|
||||||
|
return &StorageController{storageService: storageService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnection verifies if the configured Object Storage is accessible
|
||||||
|
// @Summary Test Object Storage Connection
|
||||||
|
// @Description Checks if the current configuration (DB or Env) allows access to the S3 bucket.
|
||||||
|
// @Tags admin
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /admin/storage/test-connection [post]
|
||||||
|
func (c *StorageController) TestConnection(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
err := c.storageService.TestConnection(ctx)
|
||||||
|
if err != nil {
|
||||||
|
sendError(w, http.StatusInternalServerError, "Connection failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendSuccess(w, map[string]string{"message": "Connection successful"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendError(w http.ResponseWriter, code int, message string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendSuccess(w http.ResponseWriter, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
|
|
@ -16,8 +16,9 @@ const (
|
||||||
RoleCandidate = "candidate"
|
RoleCandidate = "candidate"
|
||||||
|
|
||||||
// User Status
|
// User Status
|
||||||
UserStatusActive = "active"
|
UserStatusActive = "active"
|
||||||
UserStatusInactive = "inactive"
|
UserStatusInactive = "inactive"
|
||||||
|
UserStatusForceChangePassword = "force_change_password"
|
||||||
)
|
)
|
||||||
|
|
||||||
// User represents a user within a specific Tenant (Company).
|
// User represents a user within a specific Tenant (Company).
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,9 @@ type LoginRequest struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthResponse struct {
|
type AuthResponse struct {
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
User UserResponse `json:"user"`
|
User UserResponse `json:"user"`
|
||||||
|
MustChangePassword bool `json:"mustChangePassword"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateUserRequest struct {
|
type CreateUserRequest struct {
|
||||||
|
|
|
||||||
|
|
@ -48,11 +48,15 @@ func (uc *LoginUseCase) Execute(ctx context.Context, input dto.LoginRequest) (*d
|
||||||
fmt.Printf("[LOGIN DEBUG] Password verification PASSED\n")
|
fmt.Printf("[LOGIN DEBUG] Password verification PASSED\n")
|
||||||
|
|
||||||
// 3. Check Status
|
// 3. Check Status
|
||||||
if !strings.EqualFold(user.Status, entity.UserStatusActive) {
|
isActive := strings.EqualFold(user.Status, entity.UserStatusActive)
|
||||||
fmt.Printf("[LOGIN DEBUG] Status check FAILED: Expected %s, got '%s'\n", entity.UserStatusActive, user.Status)
|
isForceChange := strings.EqualFold(user.Status, entity.UserStatusForceChangePassword)
|
||||||
|
|
||||||
|
if !isActive && !isForceChange {
|
||||||
|
fmt.Printf("[LOGIN DEBUG] Status check FAILED: Expected %s or %s, got '%s'\n",
|
||||||
|
entity.UserStatusActive, entity.UserStatusForceChangePassword, user.Status)
|
||||||
return nil, errors.New("account inactive")
|
return nil, errors.New("account inactive")
|
||||||
}
|
}
|
||||||
fmt.Printf("[LOGIN DEBUG] Status check PASSED\n")
|
fmt.Printf("[LOGIN DEBUG] Status check PASSED (Active: %v, ForceChange: %v)\n", isActive, isForceChange)
|
||||||
|
|
||||||
// 4. Generate Token
|
// 4. Generate Token
|
||||||
roles := make([]string, len(user.Roles))
|
roles := make([]string, len(user.Roles))
|
||||||
|
|
@ -68,7 +72,8 @@ func (uc *LoginUseCase) Execute(ctx context.Context, input dto.LoginRequest) (*d
|
||||||
|
|
||||||
// 5. Return Response
|
// 5. Return Response
|
||||||
return &dto.AuthResponse{
|
return &dto.AuthResponse{
|
||||||
Token: token,
|
Token: token,
|
||||||
|
MustChangePassword: isForceChange,
|
||||||
User: dto.UserResponse{
|
User: dto.UserResponse{
|
||||||
ID: user.ID,
|
ID: user.ID,
|
||||||
Name: user.Name,
|
Name: user.Name,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
package auth_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/rede5/gohorsejobs/backend/internal/core/domain/entity"
|
||||||
|
"github.com/rede5/gohorsejobs/backend/internal/core/dto"
|
||||||
|
"github.com/rede5/gohorsejobs/backend/internal/core/usecases/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoginUseCase_Execute_ForceChangePassword(t *testing.T) {
|
||||||
|
// Setup Mocks (Reusing from register_candidate_test.go)
|
||||||
|
authSvc := &MockAuthService{
|
||||||
|
GenerateTokenFunc: func(userID, tenantID string, roles []string) (string, error) {
|
||||||
|
return "mock-token", nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
userRepo := &MockUserRepo{
|
||||||
|
FindByEmailFunc: func(ctx context.Context, email string) (*entity.User, error) {
|
||||||
|
return &entity.User{
|
||||||
|
ID: "lol-user",
|
||||||
|
Email: email,
|
||||||
|
PasswordHash: "hashed_pass",
|
||||||
|
Status: entity.UserStatusForceChangePassword,
|
||||||
|
Roles: []entity.Role{},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
uc := auth.NewLoginUseCase(userRepo, authSvc)
|
||||||
|
|
||||||
|
input := dto.LoginRequest{
|
||||||
|
Email: "lol@gohorsejobs.com",
|
||||||
|
Password: "password",
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := uc.Execute(context.Background(), input)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected no error for force_change_password status, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp == nil {
|
||||||
|
t.Fatal("Expected response, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.MustChangePassword {
|
||||||
|
t.Error("Expected MustChangePassword to be true, got false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
// Added this import
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/api/middleware"
|
"github.com/rede5/gohorsejobs/backend/internal/api/middleware"
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/database"
|
"github.com/rede5/gohorsejobs/backend/internal/database"
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/handlers"
|
"github.com/rede5/gohorsejobs/backend/internal/handlers"
|
||||||
|
|
@ -276,7 +277,7 @@ func NewRouter() http.Handler {
|
||||||
mux.HandleFunc("/docs/", httpSwagger.WrapHandler)
|
mux.HandleFunc("/docs/", httpSwagger.WrapHandler)
|
||||||
|
|
||||||
// Apply middleware chain: Security Headers -> Rate Limiting -> CORS -> Router
|
// Apply middleware chain: Security Headers -> Rate Limiting -> CORS -> Router
|
||||||
// Order matters: outer middleware runs first
|
// Order matters: outer middleware
|
||||||
var handler http.Handler = mux
|
var handler http.Handler = mux
|
||||||
handler = middleware.CORSMiddleware(handler)
|
handler = middleware.CORSMiddleware(handler)
|
||||||
handler = legacyMiddleware.SanitizeMiddleware(handler) // Sanitize XSS from JSON bodies
|
handler = legacyMiddleware.SanitizeMiddleware(handler) // Sanitize XSS from JSON bodies
|
||||||
|
|
|
||||||
|
|
@ -89,3 +89,54 @@ func (s *StorageService) GetPresignedUploadURL(ctx context.Context, key string,
|
||||||
|
|
||||||
return req.URL, nil
|
return req.URL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConnection checks if the creds are valid and bucket is accessible
|
||||||
|
func (s *StorageService) TestConnection(ctx context.Context) error {
|
||||||
|
psClient, bucket, err := s.getClient(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get client: %w", err)
|
||||||
|
}
|
||||||
|
// Note: PresignClient doesn't strictly validate creds against the cloud until used,
|
||||||
|
// checking existence via HeadBucket or ListBuckets using a real S3 client would be better.
|
||||||
|
// But getClient returns a PresignClient.
|
||||||
|
// We need a standard client to Verify.
|
||||||
|
// Re-instantiating logic or Refactoring `getClient` to return `*s3.Client` is best.
|
||||||
|
// For now, let's refactor `getClient` slightly to expose specific logic or just create a one-off checker here.
|
||||||
|
|
||||||
|
// Refetch raw creds to make a standard client
|
||||||
|
payload, err := s.credentialsService.GetDecryptedKey(ctx, "storage")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get storage credentials: %w", err)
|
||||||
|
}
|
||||||
|
var uCfg UploadConfig
|
||||||
|
if err := json.Unmarshal([]byte(payload), &uCfg); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse storage credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.LoadDefaultConfig(ctx,
|
||||||
|
config.WithRegion(uCfg.Region),
|
||||||
|
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(uCfg.AccessKey, uCfg.SecretKey, "")),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
|
||||||
|
o.BaseEndpoint = aws.String(uCfg.Endpoint)
|
||||||
|
o.UsePathStyle = true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Try HeadBucket
|
||||||
|
_, err = client.HeadBucket(ctx, &s3.HeadBucketInput{
|
||||||
|
Bucket: aws.String(uCfg.Bucket),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connection failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just to be sure, presign client creation (original logic)
|
||||||
|
_ = psClient
|
||||||
|
_ = bucket
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
12
backend/migrations/032_update_superadmin_lol.sql
Normal file
12
backend/migrations/032_update_superadmin_lol.sql
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- Migration: Update Super Admin to 'lol' and force password reset
|
||||||
|
-- Description: Updates the superadmin identifier, email, name, and sets status to enforce password change.
|
||||||
|
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
identifier = 'lol',
|
||||||
|
email = 'lol@gohorsejobs.com',
|
||||||
|
full_name = 'Dr. Horse Expert',
|
||||||
|
name = 'Dr. Horse Expert',
|
||||||
|
status = 'force_change_password',
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE identifier = 'superadmin' OR email = 'admin@gohorsejobs.com';
|
||||||
|
|
@ -6,7 +6,7 @@ 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, credentialsApi, ConfiguredService } from "@/lib/api"
|
import { settingsApi, credentialsApi, ConfiguredService, storageApi } from "@/lib/api"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Loader2, Check, Key, Trash2, Eye, EyeOff } from "lucide-react"
|
import { Loader2, Check, Key, Trash2, Eye, EyeOff } from "lucide-react"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
|
@ -46,6 +46,20 @@ export default function SettingsPage() {
|
||||||
const [credentialPayload, setCredentialPayload] = useState<any>({})
|
const [credentialPayload, setCredentialPayload] = useState<any>({})
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [testingConnection, setTestingConnection] = useState(false)
|
||||||
|
|
||||||
|
const handleTestStorageConnection = async () => {
|
||||||
|
setTestingConnection(true)
|
||||||
|
try {
|
||||||
|
const res = await storageApi.testConnection()
|
||||||
|
toast.success(res.message || "Connection successful")
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Storage connection test failed", error)
|
||||||
|
toast.error(error.message || "Connection failed")
|
||||||
|
} finally {
|
||||||
|
setTestingConnection(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fetchSettings = async () => {
|
const fetchSettings = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -303,15 +317,22 @@ export default function SettingsPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-auto">
|
<div className="flex items-center gap-2 mt-auto">
|
||||||
<Button variant="outline" size="sm" className="w-full" onClick={() => handleOpenCredentialDialog(svc.service_name)}>
|
{svc.service_name === 'storage' && svc.is_configured && (
|
||||||
<Key className="w-3 h-3 mr-2" />
|
<Button variant="secondary" size="sm" className="w-full mb-2" onClick={handleTestStorageConnection} disabled={testingConnection}>
|
||||||
{svc.is_configured ? "Edit" : "Setup"}
|
{testingConnection ? <Loader2 className="w-3 h-3 animate-spin mr-1" /> : "Test Connection"}
|
||||||
</Button>
|
|
||||||
{svc.is_configured && (
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:bg-destructive/10" onClick={() => handleDeleteCredential(svc.service_name)}>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex w-full gap-2">
|
||||||
|
<Button variant="outline" size="sm" className="w-full" onClick={() => handleOpenCredentialDialog(svc.service_name)}>
|
||||||
|
<Key className="w-3 h-3 mr-2" />
|
||||||
|
{svc.is_configured ? "Edit" : "Setup"}
|
||||||
|
</Button>
|
||||||
|
{svc.is_configured && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:bg-destructive/10" onClick={() => handleDeleteCredential(svc.service_name)}>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -718,6 +718,12 @@ export const credentialsApi = {
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const storageApi = {
|
||||||
|
testConnection: () => apiRequest<{ message: string }>("/api/v1/admin/storage/test-connection", {
|
||||||
|
method: "POST"
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
// --- Email Templates & Settings ---
|
// --- Email Templates & Settings ---
|
||||||
export interface EmailTemplate {
|
export interface EmailTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue