From 50e0cbf4420325b29fe16340529d4f91abc68b5e Mon Sep 17 00:00:00 2001 From: Justin Neubert Date: Tue, 5 Aug 2025 19:00:02 +0200 Subject: [PATCH 01/20] feat: add VerticalCollapsibleSteps component for guided user onboarding --- .../components/flows/flow/actions.tsx | 2 +- .../components/modals/user/welcome.tsx | 179 ++++++++--- .../steps/vertical-collapsible-steps.tsx | 303 ++++++++++++++++++ 3 files changed, 446 insertions(+), 38 deletions(-) create mode 100644 services/frontend/components/steps/vertical-collapsible-steps.tsx diff --git a/services/frontend/components/flows/flow/actions.tsx b/services/frontend/components/flows/flow/actions.tsx index 6470df29..692e6036 100644 --- a/services/frontend/components/flows/flow/actions.tsx +++ b/services/frontend/components/flows/flow/actions.tsx @@ -876,7 +876,7 @@ export default function Actions({ action.failure_pipeline_id === pipeline.id, ).length > 0 - ? "Assigned on Step" + ? "Assigned on Action" : "Not Assigned"} diff --git a/services/frontend/components/modals/user/welcome.tsx b/services/frontend/components/modals/user/welcome.tsx index d5d028fb..eb943b31 100644 --- a/services/frontend/components/modals/user/welcome.tsx +++ b/services/frontend/components/modals/user/welcome.tsx @@ -10,30 +10,83 @@ import { ModalBody, ModalContent, ModalFooter, - ModalHeader, + Progress, } from "@heroui/react"; -import { useRouter } from "next/navigation"; -import React from "react"; +import React, { useState } from "react"; import ErrorCard from "@/components/error/ErrorCard"; import Welcomed from "@/lib/fetch/user/PUT/welcomed"; +import VerticalCollapsibleSteps from "@/components/steps/vertical-collapsible-steps"; export default function WelcomeModal({ disclosure, }: { disclosure: UseDisclosureReturn; }) { - const router = useRouter(); const { isOpen, onOpenChange } = disclosure; - const [error, setError] = React.useState(false); - const [errorText, setErrorText] = React.useState(""); - const [errorMessage, setErrorMessage] = React.useState(""); + const [error, setError] = useState(false); + const [errorText, setErrorText] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + + const [isLoading, setLoading] = useState(false); + + const [currentStep, setCurrentStep] = useState(0); + const steps = [ + { + title: "Create a project", + description: + "Projects are the foundation of your workflows. They help you organize your flows, invite team members or create runners.", + details: [ + "Invite team members to collaborate on your project and flows.", + "Create or assign runners to execute your flows.", + "Generate API keys for secure and remote access to your project.", + "View audit logs to track changes and actions.", + ], + }, + { + title: "Create a flow", + description: + "Flows are the core functionality of exFlow. They allow you to automate tasks and processes.", + details: [ + "Specify the actions that will be taken when the flow is triggered.", + "Set up any conditions or filters to control the flow's behavior.", + "Define failover pipelines to handle errors or exceptions.", + ], + }, + { + title: "Create actions within the flow", + description: + "Actions are the building blocks of your flows. They define what happens when the flow is triggered.", + details: [ + "Actions are provided by the runner plugins.", + "Use plugins like terraform, ansible, git and many more to manage your infrastructure.", + "Create custom actions using the exFlow API to extend functionality.", + ], + }, + { + title: "Enjoy", + description: + "Now that you have set up your project and flow, you can start using exFlow and we can't wait to see what you build.", + details: [ + "Open Source and self-hosted.", + "Extensible with plugins to fit your needs.", + "Built with love by the exFlow team.", + ], + }, + ]; async function handleSetWelcomed() { + setLoading(true); + setError(false); + setErrorText(""); + setErrorMessage(""); + + // Call the API to set the welcomed status const response = (await Welcomed()) as any; if (!response) { + setLoading(false); setError(true); setErrorText("Failed to set welcomed status"); setErrorMessage("Failed to set welcomed status"); @@ -48,11 +101,13 @@ export default function WelcomeModal({ } if (response.success) { + setLoading(false); setError(false); setErrorText(""); setErrorMessage(""); onOpenChange(); } else { + setLoading(false); setError(true); setErrorText(response.error); setErrorMessage(response.message); @@ -70,47 +125,97 @@ export default function WelcomeModal({ - - {(onClose) => ( + + {() => ( <> - -
- New here, huh? - -
-
{error && ( )} -
-

- Welcome to{" "} - exFlow! - We're thrilled to have you on board. -

-

- This dialog will not be shown to you again. -

-
+

+ Welcome to{" "} + exFlow! +

+

+ This is your first time here, so we've prepared a short + guide to help you get started. +

+ +
- + {currentStep > 0 ? ( + + ) : ( + + )} + {currentStep + 1 === steps.length ? ( + + ) : ( + + )} )} diff --git a/services/frontend/components/steps/vertical-collapsible-steps.tsx b/services/frontend/components/steps/vertical-collapsible-steps.tsx new file mode 100644 index 00000000..8a42a5a9 --- /dev/null +++ b/services/frontend/components/steps/vertical-collapsible-steps.tsx @@ -0,0 +1,303 @@ +"use client"; + +import type { ComponentProps } from "react"; +import type { ButtonProps } from "@heroui/react"; + +import React from "react"; +import { Spacer } from "@heroui/react"; +import { useControlledState } from "@react-stately/utils"; +import { m, LazyMotion, domAnimation } from "framer-motion"; +import { cn } from "@heroui/react"; + +export type VerticalCollapsibleStepProps = { + className?: string; + description?: React.ReactNode; + title?: React.ReactNode; + details?: string[]; +}; + +export interface VerticalCollapsibleStepsProps + // eslint-disable-next-line no-undef + extends React.HTMLAttributes { + /** + * An array of steps. + * + * @default [] + */ + steps?: VerticalCollapsibleStepProps[]; + /** + * The color of the steps. + * + * @default "primary" + */ + color?: ButtonProps["color"]; + /** + * The current step index. + */ + currentStep?: number; + /** + * The default step index. + * + * @default 0 + */ + defaultStep?: number; + /** + * The custom class for the steps wrapper. + */ + className?: string; + /** + * The custom class for the step. + */ + stepClassName?: string; + /** + * Callback function when the step index changes. + */ + onStepChange?: (stepIndex: number) => void; +} + +function CheckIcon(props: ComponentProps<"svg">) { + return ( + + + + ); +} + +const VerticalCollapsibleSteps = React.forwardRef< + // eslint-disable-next-line no-undef + HTMLButtonElement, + VerticalCollapsibleStepsProps +>( + ( + { + color = "primary", + steps = [], + defaultStep = 0, + onStepChange, + currentStep: currentStepProp, + stepClassName, + className, + ...props + }, + ref, + ) => { + const [currentStep, setCurrentStep] = useControlledState( + currentStepProp, + defaultStep, + onStepChange, + ); + + const colors = React.useMemo(() => { + let userColor; + let fgColor; + + const colorsVars = [ + "[--active-fg-color:hsl(var(--step-fg-color))]", + "[--active-border-color:hsl(var(--step-color))]", + "[--active-color:hsl(var(--step-color))]", + "[--complete-background-color:hsl(var(--step-color))]", + "[--complete-border-color:hsl(var(--step-color))]", + "[--inactive-border-color:hsl(var(--heroui-default-300))]", + "[--inactive-color:hsl(var(--heroui-default-300))]", + ]; + + switch (color) { + case "primary": + userColor = "[--step-color:var(--heroui-primary)]"; + fgColor = "[--step-fg-color:var(--heroui-primary-foreground)]"; + break; + case "secondary": + userColor = "[--step-color:var(--heroui-secondary)]"; + fgColor = "[--step-fg-color:var(--heroui-secondary-foreground)]"; + break; + case "success": + userColor = "[--step-color:var(--heroui-success)]"; + fgColor = "[--step-fg-color:var(--heroui-success-foreground)]"; + break; + case "warning": + userColor = "[--step-color:var(--heroui-warning)]"; + fgColor = "[--step-fg-color:var(--heroui-warning-foreground)]"; + break; + case "danger": + userColor = "[--step-color:var(--heroui-error)]"; + fgColor = "[--step-fg-color:var(--heroui-error-foreground)]"; + break; + case "default": + userColor = "[--step-color:var(--heroui-default)]"; + fgColor = "[--step-fg-color:var(--heroui-default-foreground)]"; + break; + default: + userColor = "[--step-color:var(--heroui-primary)]"; + fgColor = "[--step-fg-color:var(--heroui-primary-foreground)]"; + break; + } + + colorsVars.unshift(fgColor); + colorsVars.unshift(userColor); + + return colorsVars; + }, [color]); + + return ( + + ); + }, +); + +VerticalCollapsibleSteps.displayName = "VerticalCollapsibleSteps"; + +export default VerticalCollapsibleSteps; From 919e259f1efd58134e69fbc2990098c8c2ff57a7 Mon Sep 17 00:00:00 2001 From: Justin Neubert Date: Mon, 18 Aug 2025 14:55:28 +0200 Subject: [PATCH 02/20] feat: Implement project encryption management - Updated project model to include encryption key and status. - Added functions to enable, disable, and rotate encryption keys for projects. - Integrated encryption checks in various execution and flow handlers. - Enhanced error handling for encryption-related operations. - Added new API endpoints for managing project encryption. - Updated frontend to display encryption status for projects. - Migrated existing projects to support new encryption features. --- .gitignore | 1 + ENCRYPTION_SECURITY.md | 125 ++++++++++++++ services/backend/config/config.yaml | 7 +- services/backend/config/main.go | 10 +- .../database/migrations/10_flows_type_col.go | 60 +++++++ .../migrations/7_project_encryption_keys.go | 67 ++++++++ .../8_settings_encryption_migration.go | 60 +++++++ .../migrations/9_remove_flows_cols.go | 55 ++++++ .../checkForFlowActionUpdates.go | 26 +-- .../checkHangingExecutionSteps.go | 15 +- .../checkHangingExecutions.go | 19 ++- .../checkScheduledExecutions.go | 19 ++- .../scheduleFlowExecutions.go | 19 ++- .../backend/functions/encryption/migration.go | 146 ++++++++++++++++ .../functions/encryption/old_encryption.go | 160 ++++++++++++++++++ .../backend/functions/encryption/payload.go | 63 ------- ...ssage.go => project_execution_messages.go} | 22 ++- .../functions/encryption/project_keys.go | 148 ++++++++++++++++ .../{params.go => project_params.go} | 97 +++++++++-- services/backend/go.mod | 2 +- services/backend/go.sum | 4 +- .../handlers/executions/create_step.go | 11 +- .../backend/handlers/executions/get_step.go | 21 ++- .../backend/handlers/executions/get_steps.go | 18 +- .../backend/handlers/executions/schedule.go | 12 +- .../handlers/executions/update_step.go | 15 +- .../backend/handlers/flows/add_actions.go | 13 +- .../flows/add_failure_pipeline_actions.go | 13 +- services/backend/handlers/flows/get_flow.go | 15 +- services/backend/handlers/flows/get_flows.go | 46 ++--- .../backend/handlers/flows/start_execution.go | 12 +- services/backend/handlers/flows/update.go | 6 - .../backend/handlers/flows/update_actions.go | 13 +- .../handlers/flows/update_actions_details.go | 21 +-- .../handlers/flows/update_failure_pipeline.go | 13 +- .../flows/update_failure_pipeline_actions.go | 13 +- services/backend/handlers/projects/create.go | 12 +- .../backend/handlers/projects/encryption.go | 152 +++++++++++++++++ services/backend/main.go | 19 ++- services/backend/pkg/models/projects.go | 2 + services/backend/pkg/models/settings.go | 1 + services/backend/router/projects.go | 14 ++ .../frontend/components/projects/project.tsx | 21 ++- 43 files changed, 1389 insertions(+), 199 deletions(-) create mode 100644 ENCRYPTION_SECURITY.md create mode 100644 services/backend/database/migrations/10_flows_type_col.go create mode 100644 services/backend/database/migrations/7_project_encryption_keys.go create mode 100644 services/backend/database/migrations/8_settings_encryption_migration.go create mode 100644 services/backend/database/migrations/9_remove_flows_cols.go create mode 100644 services/backend/functions/encryption/migration.go create mode 100644 services/backend/functions/encryption/old_encryption.go delete mode 100644 services/backend/functions/encryption/payload.go rename services/backend/functions/encryption/{execution_step_action_message.go => project_execution_messages.go} (65%) create mode 100644 services/backend/functions/encryption/project_keys.go rename services/backend/functions/encryption/{params.go => project_params.go} (67%) create mode 100644 services/backend/handlers/projects/encryption.go diff --git a/.gitignore b/.gitignore index 64349e68..17399d40 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ services/backend/*.exe~ services/backend/*.dll services/backend/*.so services/backend/*.dylib +services/backend/*.sql # Test binary, built with `go test -c` services/backend/*.test diff --git a/ENCRYPTION_SECURITY.md b/ENCRYPTION_SECURITY.md new file mode 100644 index 00000000..34692939 --- /dev/null +++ b/ENCRYPTION_SECURITY.md @@ -0,0 +1,125 @@ +# Secure Project-Based Encryption Setup + +## Overview + +The enhanced encryption system now uses **key derivation** instead of storing encryption keys directly in the database. This significantly improves security by ensuring that even if someone gains access to your database, they cannot decrypt the data without the master secret. + +## How It Works + +1. **Master Secret**: A single secret stored outside the database (environment variable, config file, external key management system) +2. **Project Salts**: Random salts generated per project and stored in the database +3. **Key Derivation**: Encryption keys are derived using PBKDF2(master_secret + project_salt) + +Even if an attacker gains access to your database, they only see: +- Encrypted data +- Random salts (which are useless without the master secret) + +## Configuration + +### Option 1: Environment Variable (Recommended for Production) +```bash +# Set the master secret as an environment variable +export EXFLOW_ENCRYPTION_MASTER_SECRET="your-very-long-and-secure-master-secret-here" +``` + +### Option 2: Configuration File +```yaml +# In your backend config.yaml +encryption: + master_secret: "your-very-long-and-secure-master-secret-here" + # Fallback key for legacy data (optional) + key: "legacy-key-for-backward-compatibility" +``` + +### Option 3: External Key Management (For Enterprise) +```yaml +# For advanced setups, integrate with: +# - AWS KMS +# - HashiCorp Vault +# - Azure Key Vault +# - Google Cloud KMS +``` + +## Master Secret Requirements + +- **Length**: Minimum 32 characters, recommended 64+ characters +- **Randomness**: Use a cryptographically secure random generator +- **Characters**: Include letters, numbers, and symbols +- **Uniqueness**: Must be unique per exFlow installation + +### Generate a Secure Master Secret + +```bash +# Option 1: Using OpenSSL +openssl rand -base64 64 + +# Option 2: Using Python +python3 -c "import secrets; print(secrets.token_urlsafe(64))" + +# Option 3: Using Go +go run -c "package main; import (\"crypto/rand\", \"encoding/base64\", \"fmt\"); func main() { b := make([]byte, 64); rand.Read(b); fmt.Println(base64.URLEncoding.EncodeToString(b)) }" +``` + +## Security Benefits + +1. **Database Compromise Protection**: Even with full database access, encrypted data remains secure +2. **Per-Project Isolation**: Each project uses a unique derived key +3. **Key Rotation**: Changing the master secret or project salt rotates all encryption +4. **Audit Trail**: Key derivation can be logged and monitored +5. **Compliance**: Meets most regulatory requirements for encryption key management + +## Migration from Legacy System + +The system maintains backward compatibility: + +1. **New Projects**: Automatically use the secure key derivation system +2. **Existing Projects**: Continue working with existing keys until migrated +3. **Gradual Migration**: Projects can be migrated one by one using the key rotation feature + +## Best Practices + +### Storage +- **Never** store the master secret in the database +- Use environment variables or external key management systems +- Rotate the master secret periodically (quarterly/annually) +- Keep secure backups of the master secret + +### Access Control +- Limit access to the master secret to essential personnel only +- Use separate master secrets for different environments (dev/staging/prod) +- Log all access to encryption keys + +### Monitoring +- Monitor for unusual encryption/decryption patterns +- Alert on encryption failures +- Regular security audits of key management processes + +## Troubleshooting + +### "Master secret not configured" Error +1. Ensure the master secret is set in your configuration +2. Restart the backend service after setting the secret +3. Check environment variable spelling + +### "Failed to decrypt" Errors +1. Verify the master secret hasn't changed +2. Check if the project salt was corrupted +3. Consider falling back to legacy key mode temporarily + +### Performance Considerations +- Key derivation adds ~1-2ms per operation +- Consider caching derived keys in memory for high-throughput scenarios +- Monitor CPU usage during bulk encryption operations + +## Example Implementation + +```go +// Environment variable +masterSecret := os.Getenv("EXFLOW_ENCRYPTION_MASTER_SECRET") + +// Or AWS Secrets Manager +masterSecret := getFromAWSSecretsManager("exflow/encryption/master-secret") + +// Or HashiCorp Vault +masterSecret := getFromVault("secret/exflow/encryption/master-secret") +``` diff --git a/services/backend/config/config.yaml b/services/backend/config/config.yaml index 03826a27..e9f26b73 100644 --- a/services/backend/config/config.yaml +++ b/services/backend/config/config.yaml @@ -12,9 +12,10 @@ database: password: postgres encryption: - enabled: true - # maximum 32 characters - key: null + # Minimum 32 characters, recommended 64+ characters + master_secret: "your-very-long-and-secure-master-secret-here" + # Fallback key for legacy data (optional) + key: "legacy-key-for-backward-compatibility" jwt: secret: null diff --git a/services/backend/config/main.go b/services/backend/config/main.go index 6e38ef71..07956031 100644 --- a/services/backend/config/main.go +++ b/services/backend/config/main.go @@ -46,8 +46,8 @@ type JWTConf struct { } type EncryptionConf struct { - Enabled bool `mapstructure:"enabled" validate:"required"` - Key string `mapstructure:"key"` + Key string `mapstructure:"key"` + MasterSecret string `mapstructure:"master_secret" validate:"required"` } type RunnerConf struct { @@ -84,8 +84,8 @@ func (cm *ConfigurationManager) LoadConfig(configFile string) error { "database.name": "BACKEND_DATABASE_NAME", "database.user": "BACKEND_DATABASE_USER", "database.password": "BACKEND_DATABASE_PASSWORD", - "encryption.enabled": "BACKEND_ENCRYPTION_ENABLED", "encryption.key": "BACKEND_ENCRYPTION_KEY", + "encryption.master_secret": "BACKEND_ENCRYPTION_MASTER_SECRET", "jwt.secret": "BACKEND_JWT_SECRET", "runner.shared_runner_secret": "BACKEND_RUNNER_SHARED_RUNNER_SECRET", } @@ -118,6 +118,10 @@ func (cm *ConfigurationManager) LoadConfig(configFile string) error { // Assign to package-level variable for global access Config = &config + if config.Encryption.MasterSecret == "" { + log.Fatal("Master secret is required for encryption") + } + log.WithFields(log.Fields{ "file": configFile, "content": cm.viper.AllSettings(), diff --git a/services/backend/database/migrations/10_flows_type_col.go b/services/backend/database/migrations/10_flows_type_col.go new file mode 100644 index 00000000..e5bdc6f5 --- /dev/null +++ b/services/backend/database/migrations/10_flows_type_col.go @@ -0,0 +1,60 @@ +package migrations + +import ( + "context" + "fmt" + + log "github.com/sirupsen/logrus" + "github.com/uptrace/bun" +) + +func init() { + Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error { + return addTypeToFlows(ctx, db) + }, func(ctx context.Context, db *bun.DB) error { + return removeTypeFromFlows(ctx, db) + }) +} + +func addTypeToFlows(ctx context.Context, db *bun.DB) error { + // add type column + exists, err := columnExists(ctx, db, "flows", "type") + if err != nil { + return fmt.Errorf("failed to check if type column exists: %v", err) + } + if !exists { + _, err := db.NewAddColumn(). + Table("flows"). + ColumnExpr("type TEXT DEFAULT 'default'"). + Exec(ctx) + + if err != nil { + return fmt.Errorf("failed to add type column to flows table: %v", err) + } + } else { + log.Debug("type column already exists in flows table") + } + + return nil +} + +func removeTypeFromFlows(ctx context.Context, db *bun.DB) error { + exists, err := columnExists(ctx, db, "flows", "type") + if err != nil { + return fmt.Errorf("failed to check if type column exists: %v", err) + } + if exists { + _, err := db.NewDropColumn(). + Table("flows"). + Column("type"). + Exec(ctx) + + if err != nil { + return fmt.Errorf("failed to remove type column from flows table: %v", err) + } + } else { + log.Debug("type column already removed from flows table") + } + + return nil +} diff --git a/services/backend/database/migrations/7_project_encryption_keys.go b/services/backend/database/migrations/7_project_encryption_keys.go new file mode 100644 index 00000000..e7c7bf94 --- /dev/null +++ b/services/backend/database/migrations/7_project_encryption_keys.go @@ -0,0 +1,67 @@ +package migrations + +import ( + "context" + "crypto/rand" + "encoding/hex" + + "github.com/uptrace/bun" +) + +func init() { + Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error { + // Add encryption_key column to projects table + _, err := db.ExecContext(ctx, "ALTER TABLE projects ADD COLUMN IF NOT EXISTS encryption_key TEXT DEFAULT ''") + if err != nil { + return err + } + + // Add encryption_enabled column to projects table + _, err = db.ExecContext(ctx, "ALTER TABLE projects ADD COLUMN IF NOT EXISTS encryption_enabled BOOLEAN DEFAULT true") + if err != nil { + return err + } + + // Generate encryption salts for existing projects that don't have them + rows, err := db.QueryContext(ctx, "SELECT id FROM projects WHERE encryption_key = '' OR encryption_key IS NULL") + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var projectID string + if err := rows.Scan(&projectID); err != nil { + continue // Skip problematic rows, don't fail the entire migration + } + + // Generate a new 32-byte salt (not a key!) + salt := make([]byte, 32) + if _, err := rand.Read(salt); err != nil { + continue // Skip if salt generation fails + } + hexSalt := hex.EncodeToString(salt) + + // Update the project with the new encryption salt + _, err = db.ExecContext(ctx, "UPDATE projects SET encryption_key = $1, encryption_enabled = true WHERE id = $2", hexSalt, projectID) + if err != nil { + continue // Skip if update fails + } + } + + return nil + }, func(ctx context.Context, db *bun.DB) error { + // Drop the added columns + _, err := db.ExecContext(ctx, "ALTER TABLE projects DROP COLUMN IF EXISTS encryption_key") + if err != nil { + return err + } + + _, err = db.ExecContext(ctx, "ALTER TABLE projects DROP COLUMN IF EXISTS encryption_enabled") + if err != nil { + return err + } + + return nil + }) +} diff --git a/services/backend/database/migrations/8_settings_encryption_migration.go b/services/backend/database/migrations/8_settings_encryption_migration.go new file mode 100644 index 00000000..61e6ae2c --- /dev/null +++ b/services/backend/database/migrations/8_settings_encryption_migration.go @@ -0,0 +1,60 @@ +package migrations + +import ( + "context" + "fmt" + + log "github.com/sirupsen/logrus" + "github.com/uptrace/bun" +) + +func init() { + Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error { + return addNewEncryptionMigratedToSettings(ctx, db) + }, func(ctx context.Context, db *bun.DB) error { + return removeNewEncryptionMigratedFromSettings(ctx, db) + }) +} + +func addNewEncryptionMigratedToSettings(ctx context.Context, db *bun.DB) error { + // add new_encryption_migrated column + exists, err := columnExists(ctx, db, "settings", "new_encryption_migrated") + if err != nil { + return fmt.Errorf("failed to check if new_encryption_migrated column exists: %v", err) + } + if !exists { + _, err := db.NewAddColumn(). + Table("settings"). + ColumnExpr("new_encryption_migrated BOOLEAN DEFAULT FALSE"). + Exec(ctx) + + if err != nil { + return fmt.Errorf("failed to add new_encryption_migrated column to settings table: %v", err) + } + } else { + log.Debug("new_encryption_migrated column already exists in settings table") + } + + return nil +} + +func removeNewEncryptionMigratedFromSettings(ctx context.Context, db *bun.DB) error { + exists, err := columnExists(ctx, db, "settings", "new_encryption_migrated") + if err != nil { + return fmt.Errorf("failed to check if new_encryption_migrated column exists: %v", err) + } + if exists { + _, err := db.NewDropColumn(). + Table("settings"). + Column("new_encryption_migrated"). + Exec(ctx) + + if err != nil { + return fmt.Errorf("failed to remove new_encryption_migrated column from settings table: %v", err) + } + } else { + log.Debug("new_encryption_migrated column already removed from settings table") + } + + return nil +} diff --git a/services/backend/database/migrations/9_remove_flows_cols.go b/services/backend/database/migrations/9_remove_flows_cols.go new file mode 100644 index 00000000..5437f901 --- /dev/null +++ b/services/backend/database/migrations/9_remove_flows_cols.go @@ -0,0 +1,55 @@ +package migrations + +import ( + "context" + "fmt" + + log "github.com/sirupsen/logrus" + "github.com/uptrace/bun" +) + +func init() { + Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error { + return removeColsFromFlow(ctx, db) + }, func(ctx context.Context, db *bun.DB) error { + return removeColsFromFlow(ctx, db) + }) +} + +func removeColsFromFlow(ctx context.Context, db *bun.DB) error { + exists, err := columnExists(ctx, db, "flows", "encrypt_action_params") + if err != nil { + return fmt.Errorf("failed to check if encrypt_action_params column exists: %v", err) + } + if exists { + _, err := db.NewDropColumn(). + Table("flows"). + Column("encrypt_action_params"). + Exec(ctx) + + if err != nil { + return fmt.Errorf("failed to remove encrypt_action_params column from flows table: %v", err) + } + } else { + log.Debug("encrypt_action_params column already removed from flows table") + } + + exists, err = columnExists(ctx, db, "flows", "encrypt_executions") + if err != nil { + return fmt.Errorf("failed to check if encrypt_executions column exists: %v", err) + } + if exists { + _, err := db.NewDropColumn(). + Table("flows"). + Column("encrypt_executions"). + Exec(ctx) + + if err != nil { + return fmt.Errorf("failed to remove encrypt_executions column from flows table: %v", err) + } + } else { + log.Debug("encrypt_executions column already removed from flows table") + } + + return nil +} diff --git a/services/backend/functions/background_checks/checkForFlowActionUpdates.go b/services/backend/functions/background_checks/checkForFlowActionUpdates.go index de42daaa..1cbba7e6 100644 --- a/services/backend/functions/background_checks/checkForFlowActionUpdates.go +++ b/services/backend/functions/background_checks/checkForFlowActionUpdates.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/Masterminds/semver" - "github.com/v1Flows/exFlow/services/backend/config" "github.com/v1Flows/exFlow/services/backend/functions/encryption" "github.com/v1Flows/exFlow/services/backend/pkg/models" shared_models "github.com/v1Flows/shared-library/pkg/models" @@ -54,10 +53,17 @@ func processFlowsForProject(db *bun.DB, context context.Context, projectID strin return } + // get project data + var project models.Projects + err = db.NewSelect().Model(&project).Where("id = ?", projectID).Scan(context) + if err != nil { + return + } + // Process each flow for _, flow := range flows { updatedFlow := deepcopy.Copy(flow).(models.Flows) // Deep copy the flow - updateFlowActions(&updatedFlow, runners) + updateFlowActions(&updatedFlow, runners, project, db) // Write updated flow to the database _, err := db.NewUpdate().Model(&updatedFlow).Where("id = ?", updatedFlow.ID).Set("failure_pipelines = ?, actions = ?", updatedFlow.FailurePipelines, updatedFlow.Actions).Exec(context) @@ -67,7 +73,7 @@ func processFlowsForProject(db *bun.DB, context context.Context, projectID strin } } -func updateFlowActions(flow *models.Flows, runners []models.Runners) { +func updateFlowActions(flow *models.Flows, runners []models.Runners, project models.Projects, db *bun.DB) { // Check for action updates in the flow itself for j, action := range flow.Actions { if len(runners) == 0 { @@ -78,7 +84,7 @@ func updateFlowActions(flow *models.Flows, runners []models.Runners) { flow.Actions[j] = action } } else { - updatedAction := updateActionIfNeeded(flow, action, runners) + updatedAction := updateActionIfNeeded(flow, action, runners, project, db) flow.Actions[j] = updatedAction } } @@ -96,7 +102,7 @@ func updateFlowActions(flow *models.Flows, runners []models.Runners) { updatedPipeline.Actions[j] = action } } else { - updatedAction := updateActionIfNeeded(flow, action, runners) + updatedAction := updateActionIfNeeded(flow, action, runners, project, db) updatedPipeline.Actions[j] = updatedAction } } @@ -104,7 +110,7 @@ func updateFlowActions(flow *models.Flows, runners []models.Runners) { } } -func updateActionIfNeeded(flow *models.Flows, action shared_models.Action, runners []models.Runners) shared_models.Action { +func updateActionIfNeeded(flow *models.Flows, action shared_models.Action, runners []models.Runners, project models.Projects, db *bun.DB) shared_models.Action { for _, runner := range runners { for _, plugin := range runner.Plugins { if action.Plugin == strings.ToLower(plugin.Name) { @@ -121,7 +127,7 @@ func updateActionIfNeeded(flow *models.Flows, action shared_models.Action, runne } if pluginVersion.GreaterThan(actionVersion) { - return createUpdatedAction(flow, action, plugin) + return createUpdatedAction(flow, action, plugin, project, db) } } } @@ -129,7 +135,7 @@ func updateActionIfNeeded(flow *models.Flows, action shared_models.Action, runne return action } -func createUpdatedAction(flow *models.Flows, action shared_models.Action, plugin shared_models.Plugin) shared_models.Action { +func createUpdatedAction(flow *models.Flows, action shared_models.Action, plugin shared_models.Plugin, project models.Projects, db *bun.DB) shared_models.Action { updatedAction := deepcopy.Copy(action).(shared_models.Action) // Deep copy the action updatedAction.UpdateAvailable = true updatedAction.UpdateVersion = plugin.Version @@ -150,9 +156,9 @@ func createUpdatedAction(flow *models.Flows, action shared_models.Action, plugin // Otherwise, use the default value updatedAction.UpdatedAction.Params[uP].Value = updatedParam.Default - if config.Config.Encryption.Enabled && flow.EncryptActionParams { + if project.EncryptionEnabled { var err error - updatedAction.UpdatedAction.Params[uP], err = encryption.EncryptParam(updatedAction.UpdatedAction.Params[uP]) + updatedAction.UpdatedAction.Params[uP], err = encryption.EncryptParamWithProject(updatedAction.UpdatedAction.Params[uP], project.ID.String(), db) if err != nil { log.Errorf("Bot: Error encrypting action param %s: %v", updatedAction.UpdatedAction.Params[uP].Key, err) } diff --git a/services/backend/functions/background_checks/checkHangingExecutionSteps.go b/services/backend/functions/background_checks/checkHangingExecutionSteps.go index d05072b1..a8b69eba 100644 --- a/services/backend/functions/background_checks/checkHangingExecutionSteps.go +++ b/services/backend/functions/background_checks/checkHangingExecutionSteps.go @@ -42,11 +42,18 @@ func checkHangingExecutionSteps(db *bun.DB) { continue } + // get project data + var project models.Projects + err = db.NewSelect().Model(&project).Where("id = ?", flow.ProjectID).Scan(context) + if err != nil { + return + } + // if the execution is finished, let the step fail if execution.Status == "success" || execution.Status == "error" || execution.Status == "canceled" || execution.Status == "noPatternMatch" || execution.Status == "recovered" { // check for encryption and decrypt messages - if flow.EncryptExecutions && step.Messages != nil && len(step.Messages) > 0 { - step.Messages, err = encryption.DecryptExecutionStepActionMessage(step.Messages) + if project.EncryptionEnabled && step.Messages != nil && len(step.Messages) > 0 { + step.Messages, err = encryption.DecryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { log.Error("Bot: Error encrypting execution step action messages", err) } @@ -68,8 +75,8 @@ func checkHangingExecutionSteps(db *bun.DB) { }) // check for encryption and encrypt messages - if flow.EncryptExecutions && step.Messages != nil && len(step.Messages) > 0 { - step.Messages, err = encryption.EncryptExecutionStepActionMessage(step.Messages) + if project.EncryptionEnabled && step.Messages != nil && len(step.Messages) > 0 { + step.Messages, err = encryption.EncryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { log.Error("Bot: Error encrypting execution step action messages", err) } diff --git a/services/backend/functions/background_checks/checkHangingExecutions.go b/services/backend/functions/background_checks/checkHangingExecutions.go index 2021416c..e3c94863 100644 --- a/services/backend/functions/background_checks/checkHangingExecutions.go +++ b/services/backend/functions/background_checks/checkHangingExecutions.go @@ -38,6 +38,13 @@ func checkHangingExecutions(db *bun.DB) { log.Error("Bot: Error getting flow data", err) } + // get project data + var project models.Projects + err = db.NewSelect().Model(&project).Where("id = ?", flow.ProjectID).Scan(context) + if err != nil { + return + } + step := shared_models.ExecutionSteps{ ExecutionID: execution.ID.String(), Action: shared_models.Action{ @@ -68,8 +75,8 @@ func checkHangingExecutions(db *bun.DB) { } // check for encryption - if flow.EncryptExecutions && step.Messages != nil && len(step.Messages) > 0 { - step.Messages, err = encryption.EncryptExecutionStepActionMessage(step.Messages) + if project.EncryptionEnabled && step.Messages != nil && len(step.Messages) > 0 { + step.Messages, err = encryption.EncryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { log.Error("Bot: Error encrypting execution step action messages", err) } @@ -103,8 +110,8 @@ func checkHangingExecutions(db *bun.DB) { step.CanceledBy = "Automated Check" // check for encryption and decrypt messages - if flow.EncryptExecutions && step.Messages != nil && len(step.Messages) > 0 { - step.Messages, err = encryption.DecryptExecutionStepActionMessage(step.Messages) + if project.EncryptionEnabled && step.Messages != nil && len(step.Messages) > 0 { + step.Messages, err = encryption.DecryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { log.Error("Bot: Error encrypting execution step action messages", err) } @@ -124,8 +131,8 @@ func checkHangingExecutions(db *bun.DB) { }) // check for encryption and encrypt messages - if flow.EncryptExecutions && step.Messages != nil && len(step.Messages) > 0 { - step.Messages, err = encryption.EncryptExecutionStepActionMessage(step.Messages) + if project.EncryptionEnabled && step.Messages != nil && len(step.Messages) > 0 { + step.Messages, err = encryption.EncryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { log.Error("Bot: Error encrypting execution step action messages", err) } diff --git a/services/backend/functions/background_checks/checkScheduledExecutions.go b/services/backend/functions/background_checks/checkScheduledExecutions.go index fa444397..b36fe007 100644 --- a/services/backend/functions/background_checks/checkScheduledExecutions.go +++ b/services/backend/functions/background_checks/checkScheduledExecutions.go @@ -33,6 +33,13 @@ func checkScheduledExecutions(db *bun.DB) { log.Error("Bot: Error getting flow data", err) } + // get project data + var project models.Projects + err = db.NewSelect().Model(&project).Where("id = ?", flow.ProjectID).Scan(context) + if err != nil { + return + } + // update the scheduled step to success var steps []models.ExecutionSteps err = db.NewSelect().Model(&steps).Where("execution_id = ?", execution.ID).Scan(context) @@ -47,8 +54,8 @@ func checkScheduledExecutions(db *bun.DB) { step.FinishedAt = time.Now() // check for encryption - if flow.EncryptExecutions && step.Messages != nil && len(step.Messages) > 0 { - step.Messages, err = encryption.DecryptExecutionStepActionMessage(step.Messages) + if project.EncryptionEnabled && step.Messages != nil && len(step.Messages) > 0 { + step.Messages, err = encryption.DecryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { log.Error("Bot: Error encrypting execution step action messages", err) } @@ -68,8 +75,8 @@ func checkScheduledExecutions(db *bun.DB) { }) // check for encryption - if flow.EncryptExecutions && step.Messages != nil && len(step.Messages) > 0 { - step.Messages, err = encryption.EncryptExecutionStepActionMessage(step.Messages) + if project.EncryptionEnabled && step.Messages != nil && len(step.Messages) > 0 { + step.Messages, err = encryption.EncryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { log.Error("Bot: Error encrypting execution step action messages", err) } @@ -106,8 +113,8 @@ func checkScheduledExecutions(db *bun.DB) { } // check for encryption - if flow.EncryptExecutions && step.Messages != nil && len(step.Messages) > 0 { - step.Messages, err = encryption.EncryptExecutionStepActionMessage(step.Messages) + if project.EncryptionEnabled && step.Messages != nil && len(step.Messages) > 0 { + step.Messages, err = encryption.EncryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { log.Error("Bot: Error encrypting execution step action messages", err) } diff --git a/services/backend/functions/background_checks/scheduleFlowExecutions.go b/services/backend/functions/background_checks/scheduleFlowExecutions.go index 30ca944c..38d517ee 100644 --- a/services/backend/functions/background_checks/scheduleFlowExecutions.go +++ b/services/backend/functions/background_checks/scheduleFlowExecutions.go @@ -27,6 +27,13 @@ func scheduleFlowExecutions(db *bun.DB) { // schedule new executions for each flow based on the schedule for _, flow := range flows { + // get project data + var project models.Projects + err = db.NewSelect().Model(&project).Where("id = ?", flow.ProjectID).Scan(context) + if err != nil { + return + } + // get all executions for that flow that are triggered by schedule var lastScheduledExecution []models.Executions count, err := db.NewSelect(). @@ -46,10 +53,10 @@ func scheduleFlowExecutions(db *bun.DB) { var currentTime time.Time if count == 0 { currentTime = time.Now() - returnedExecutionTime := createExecution(currentTime, flow, db, context) + returnedExecutionTime := createExecution(currentTime, flow, db, context, project) // directly schedule the next execution - createExecution(returnedExecutionTime, flow, db, context) + createExecution(returnedExecutionTime, flow, db, context, project) } else { currentTime = lastScheduledExecution[0].ScheduledAt @@ -57,13 +64,13 @@ func scheduleFlowExecutions(db *bun.DB) { currentTime = time.Now() } - createExecution(currentTime, flow, db, context) + createExecution(currentTime, flow, db, context, project) } } } -func createExecution(currentTime time.Time, flow models.Flows, db *bun.DB, context context.Context) (scheduledAt time.Time) { +func createExecution(currentTime time.Time, flow models.Flows, db *bun.DB, context context.Context, project models.Projects) (scheduledAt time.Time) { // calculate the next execution time var nextExecutionTime time.Time switch flow.ScheduleEveryUnit { @@ -119,8 +126,8 @@ func createExecution(currentTime time.Time, flow models.Flows, db *bun.DB, conte } // check for encryption - if flow.EncryptExecutions && step.Messages != nil && len(step.Messages) > 0 { - step.Messages, err = encryption.EncryptExecutionStepActionMessage(step.Messages) + if project.EncryptionEnabled && step.Messages != nil && len(step.Messages) > 0 { + step.Messages, err = encryption.EncryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { log.Error("Bot: Error encrypting execution step action messages. ", err) return diff --git a/services/backend/functions/encryption/migration.go b/services/backend/functions/encryption/migration.go new file mode 100644 index 00000000..31e597ba --- /dev/null +++ b/services/backend/functions/encryption/migration.go @@ -0,0 +1,146 @@ +package encryption + +import ( + "context" + + "github.com/uptrace/bun" + "github.com/v1Flows/exFlow/services/backend/pkg/models" + + log "github.com/sirupsen/logrus" +) + +// MigrateProjectEncryption migrates all encrypted data in a project from old key to new key +// This is useful when rotating encryption keys or migrating from global to project-specific encryption +func MigrateProjectsEncryption(oldKey string, db *bun.DB) error { + + // check if already migrated + var settings models.Settings + err := db.NewSelect().Model(&settings).Where("id = ?", 1).Scan(context.Background()) + if err != nil { + return err + } + if settings.NewEncryptionMigrated { + if oldKey != "" { + log.Info("Projects already migrated, skipping migration. You can remove the old key from the config.") + } + return nil + } + + log.Info("Migrating projects to new encryption algorithm...") + + var projects []models.Projects + err = db.NewSelect().Model(&projects).Scan(context.Background()) + if err != nil { + return err + } + + for _, project := range projects { + err = EnableProjectEncryption(project.ID.String(), db) + if err != nil { + return err + } + + // Get all flows for this project + var flows []models.Flows + err := db.NewSelect().Model(&flows).Where("project_id = ?", project.ID).Scan(context.Background()) + if err != nil { + return err + } + + for _, flow := range flows { + // Decrypt with old key and re-encrypt with new key + if len(flow.Actions) > 0 { + // Temporarily decrypt with old key + decryptedActions, err := DecryptParams(flow.Actions, true) + if err != nil { + continue + } + + // Re-encrypt with new encryption + encryptedActions, err := EncryptParamsWithProject(decryptedActions, flow.ProjectID, db) + if err != nil { + return err + } + + // Update the flow with re-encrypted data + _, err = db.NewUpdate().Model(&flow).Set("actions = ?", encryptedActions).Where("id = ?", flow.ID).Exec(context.Background()) + if err != nil { + return err + } + } + + // Handle failure pipeline actions + for i, pipeline := range flow.FailurePipelines { + if len(pipeline.Actions) > 0 { + // Temporarily decrypt with old key + decryptedActions, err := DecryptParams(pipeline.Actions, true) + if err != nil { + continue + } + + // Re-encrypt with new key + encryptedActions, err := EncryptParamsWithProject(decryptedActions, flow.ProjectID, db) + if err != nil { + return err + } + + flow.FailurePipelines[i].Actions = encryptedActions + } + } + + // Update failure pipelines + _, err = db.NewUpdate().Model(&flow).Set("failure_pipelines = ?", flow.FailurePipelines).Where("id = ?", flow.ID).Exec(context.Background()) + if err != nil { + return err + } + + // migrate execution step messages + var executions []models.Executions + err = db.NewSelect().Model(&executions).Where("flow_id = ?", flow.ID).Scan(context.Background()) + if err != nil { + return err + } + + for _, execution := range executions { + var steps []models.ExecutionSteps + err = db.NewSelect().Model(&steps).Where("execution_id = ?", execution.ID).Scan(context.Background()) + if err != nil { + return err + } + + for _, step := range steps { + // Decrypt with old key and re-encrypt with new key + if len(step.Messages) > 0 { + // Temporarily decrypt with old key + decryptedMessages, err := DecryptExecutionStepActionMessage(step.Messages) + if err != nil { + continue + } + + // Re-encrypt with new encryption + encryptedMessages, err := EncryptExecutionStepActionMessageWithProject(decryptedMessages, project.ID.String(), db) + if err != nil { + return err + } + + // Update the step with re-encrypted data + _, err = db.NewUpdate().Model(&step).Set("messages = ?", encryptedMessages).Where("id = ?", step.ID).Exec(context.Background()) + if err != nil { + return err + } + } + } + } + } + } + + // set new_encryption_migrated in settings + _, err = db.NewUpdate().Model(&models.Settings{}).Set("new_encryption_migrated = ?", true).Where("id = ?", 1).Exec(context.Background()) + if err != nil { + return err + } + + log.Info("Projects migrated successfully") + + return nil +} diff --git a/services/backend/functions/encryption/old_encryption.go b/services/backend/functions/encryption/old_encryption.go new file mode 100644 index 00000000..c5f18d6d --- /dev/null +++ b/services/backend/functions/encryption/old_encryption.go @@ -0,0 +1,160 @@ +package encryption + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + + "github.com/v1Flows/exFlow/services/backend/config" + shared_models "github.com/v1Flows/shared-library/pkg/models" +) + +func DecryptParams(actions []shared_models.Action, decryptPasswords bool) ([]shared_models.Action, error) { + block, err := aes.NewCipher([]byte(config.Config.Encryption.Key)) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + for i, action := range actions { + for j, param := range action.Params { + // Skip decryption if the value is empty + if param.Value == "" { + continue + } + + if param.Type == "password" && !decryptPasswords { + continue + } + + // Skip decryption if the value is not encrypted + if !IsEncrypted(param.Value) { + continue + } + + // Decode the hex string + ciphertext, err := hex.DecodeString(param.Value) + if err != nil { + return nil, errors.New("failed to decode hex string: " + err.Error()) + } + + if len(ciphertext) < gcm.NonceSize() { + return nil, errors.New("ciphertext too short") + } + + // Extract the nonce and ciphertext + nonce := ciphertext[:gcm.NonceSize()] + ciphertext = ciphertext[gcm.NonceSize():] + + // Decrypt the ciphertext + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, errors.New("failed to decrypt: " + err.Error()) + } + + // Convert the decrypted JSON value back to the original type + var originalValue interface{} + if err := json.Unmarshal(plaintext, &originalValue); err != nil { + return nil, err + } + + param.Value = fmt.Sprintf("%v", originalValue) + actions[i].Params[j] = param + } + + // if action has an updated action, decrypt the params of the updated action + if action.UpdatedAction != nil { + for j, param := range action.UpdatedAction.Params { + // skip if value is not encrypted + if !IsEncrypted(param.Value) { + continue + } + + // Skip decryption if the value is empty + if param.Value == "" { + continue + } + + if param.Type == "password" && !decryptPasswords { + continue + } + + // Decode the hex string + ciphertext, err := hex.DecodeString(param.Value) + if err != nil { + return nil, errors.New("failed to decode hex string: " + err.Error()) + } + + if len(ciphertext) < gcm.NonceSize() { + return nil, errors.New("ciphertext too short") + } + + // Extract the nonce and ciphertext + nonce := ciphertext[:gcm.NonceSize()] + ciphertext = ciphertext[gcm.NonceSize():] + + // Decrypt the ciphertext + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, errors.New("failed to decrypt: " + err.Error()) + } + + // Convert the decrypted JSON value back to the original type + var originalValue interface{} + if err := json.Unmarshal(plaintext, &originalValue); err != nil { + return nil, err + } + + param.Value = fmt.Sprintf("%v", originalValue) + action.UpdatedAction.Params[j] = param + } + } + } + + return actions, nil +} + +func DecryptExecutionStepActionMessage(encryptedMessage []shared_models.Message) ([]shared_models.Message, error) { + block, err := aes.NewCipher([]byte(config.Config.Encryption.Key)) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + for i := range encryptedMessage { + for line := range encryptedMessage[i].Lines { + encodedCiphertext := encryptedMessage[i].Lines[line].Content + ciphertext, err := base64.StdEncoding.DecodeString(encodedCiphertext) + if err != nil { + return nil, err + } + + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return nil, errors.New("ciphertext too short") + } + + nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, err + } + + encryptedMessage[i].Lines[line].Content = string(plaintext) + } + } + + return encryptedMessage, nil +} diff --git a/services/backend/functions/encryption/payload.go b/services/backend/functions/encryption/payload.go deleted file mode 100644 index 8b31837b..00000000 --- a/services/backend/functions/encryption/payload.go +++ /dev/null @@ -1,63 +0,0 @@ -package encryption - -import ( - "github.com/v1Flows/exFlow/services/backend/config" - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "encoding/base64" - "encoding/json" - "io" -) - -func EncryptPayload(payload json.RawMessage) (json.RawMessage, error) { - block, err := aes.NewCipher([]byte(config.Config.Encryption.Key)) - if err != nil { - return nil, err - } - - plaintext := []byte(payload) - ciphertext := make([]byte, aes.BlockSize+len(plaintext)) - iv := ciphertext[:aes.BlockSize] - - if _, err := io.ReadFull(rand.Reader, iv); err != nil { - return nil, err - } - - stream := cipher.NewCFBEncrypter(block, iv) - stream.XORKeyStream(ciphertext[aes.BlockSize:], plaintext) - - // Encode the ciphertext as base64 to ensure it can be stored as JSON - encodedCiphertext := base64.StdEncoding.EncodeToString(ciphertext) - encryptedPayload, err := json.Marshal(encodedCiphertext) - if err != nil { - return nil, err - } - - return json.RawMessage(encryptedPayload), nil -} - -func DecryptPayload(payload json.RawMessage) (json.RawMessage, error) { - block, err := aes.NewCipher([]byte(config.Config.Encryption.Key)) - if err != nil { - return nil, err - } - - var encodedCiphertext string - if err := json.Unmarshal(payload, &encodedCiphertext); err != nil { - return nil, err - } - - ciphertext, err := base64.StdEncoding.DecodeString(encodedCiphertext) - if err != nil { - return nil, err - } - - iv := ciphertext[:aes.BlockSize] - ciphertext = ciphertext[aes.BlockSize:] - - stream := cipher.NewCFBDecrypter(block, iv) - stream.XORKeyStream(ciphertext, ciphertext) - - return json.RawMessage(ciphertext), nil -} diff --git a/services/backend/functions/encryption/execution_step_action_message.go b/services/backend/functions/encryption/project_execution_messages.go similarity index 65% rename from services/backend/functions/encryption/execution_step_action_message.go rename to services/backend/functions/encryption/project_execution_messages.go index 776b0f40..1d1ba952 100644 --- a/services/backend/functions/encryption/execution_step_action_message.go +++ b/services/backend/functions/encryption/project_execution_messages.go @@ -8,12 +8,18 @@ import ( "errors" "io" - "github.com/v1Flows/exFlow/services/backend/config" + "github.com/uptrace/bun" shared_models "github.com/v1Flows/shared-library/pkg/models" ) -func EncryptExecutionStepActionMessage(messages []shared_models.Message) ([]shared_models.Message, error) { - block, err := aes.NewCipher([]byte(config.Config.Encryption.Key)) +// EncryptExecutionStepActionMessageWithProject encrypts execution step messages using project-specific encryption +func EncryptExecutionStepActionMessageWithProject(messages []shared_models.Message, projectID string, db *bun.DB) ([]shared_models.Message, error) { + encryptionKey, err := getEncryptionKey(projectID, db) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(encryptionKey) if err != nil { return nil, err } @@ -43,8 +49,14 @@ func EncryptExecutionStepActionMessage(messages []shared_models.Message) ([]shar return messages, nil } -func DecryptExecutionStepActionMessage(encryptedMessage []shared_models.Message) ([]shared_models.Message, error) { - block, err := aes.NewCipher([]byte(config.Config.Encryption.Key)) +// DecryptExecutionStepActionMessageWithProject decrypts execution step messages using project-specific encryption +func DecryptExecutionStepActionMessageWithProject(encryptedMessage []shared_models.Message, projectID string, db *bun.DB) ([]shared_models.Message, error) { + encryptionKey, err := getEncryptionKey(projectID, db) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(encryptionKey) if err != nil { return nil, err } diff --git a/services/backend/functions/encryption/project_keys.go b/services/backend/functions/encryption/project_keys.go new file mode 100644 index 00000000..e4941009 --- /dev/null +++ b/services/backend/functions/encryption/project_keys.go @@ -0,0 +1,148 @@ +package encryption + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + + "golang.org/x/crypto/pbkdf2" + + "github.com/uptrace/bun" + "github.com/v1Flows/exFlow/services/backend/config" + "github.com/v1Flows/exFlow/services/backend/pkg/models" +) + +// GenerateProjectSalt generates a new random salt for a project +func GenerateProjectSalt() (string, error) { + salt := make([]byte, 32) // 256-bit salt + _, err := rand.Read(salt) + if err != nil { + return "", err + } + return hex.EncodeToString(salt), nil +} + +// DeriveProjectEncryptionKey derives an encryption key from master secret + project salt +func DeriveProjectEncryptionKey(projectSalt string, masterSecret string) ([]byte, error) { + if masterSecret == "" { + return nil, errors.New("master secret not configured") + } + + saltBytes, err := hex.DecodeString(projectSalt) + if err != nil { + return nil, err + } + + // Use PBKDF2 to derive a 32-byte key from master secret + project salt + // 100,000 iterations should be sufficient for this use case + key := pbkdf2.Key([]byte(masterSecret), saltBytes, 100000, 32, sha256.New) + return key, nil +} + +// GetProjectEncryptionKey retrieves the encryption key for a specific project +func GetProjectEncryptionKey(projectID string, db *bun.DB) ([]byte, error) { + var project models.Projects + err := db.NewSelect().Model(&project).Where("id = ?", projectID).Scan(context.Background()) + if err != nil { + return nil, err + } + + if !project.EncryptionEnabled { + return nil, errors.New("encryption is disabled for this project") + } + + if project.EncryptionKey == "" { + return nil, errors.New("encryption salt not found for project") + } + + // Derive the actual encryption key from master secret + project salt + masterSecret := config.Config.Encryption.MasterSecret + if masterSecret == "" { + // Fall back to legacy key storage if master secret not configured + keyBytes, err := hex.DecodeString(project.EncryptionKey) + if err != nil { + return nil, err + } + return keyBytes, nil + } + + return DeriveProjectEncryptionKey(project.EncryptionKey, masterSecret) +} + +// SetProjectEncryptionSalt sets the encryption salt for a specific project +func SetProjectEncryptionSalt(projectID string, encryptionSalt string, db *bun.DB) error { + _, err := db.NewUpdate(). + Model((*models.Projects)(nil)). + Set("encryption_key = ?, encryption_enabled = ?", encryptionSalt, true). + Where("id = ?", projectID). + Exec(context.Background()) + + return err +} + +// EnableProjectEncryption enables encryption for a project and generates a new salt if one doesn't exist +func EnableProjectEncryption(projectID string, db *bun.DB) error { + var project models.Projects + err := db.NewSelect().Model(&project).Where("id = ?", projectID).Scan(context.Background()) + if err != nil { + return err + } + + // Generate a new salt if one doesn't exist + if project.EncryptionKey == "" { + newSalt, err := GenerateProjectSalt() + if err != nil { + return err + } + + _, err = db.NewUpdate(). + Model((*models.Projects)(nil)). + Set("encryption_key = ?, encryption_enabled = ?", newSalt, true). + Where("id = ?", projectID). + Exec(context.Background()) + + return err + } + + // Just enable encryption if salt already exists + _, err = db.NewUpdate(). + Model((*models.Projects)(nil)). + Set("encryption_enabled = ?", true). + Where("id = ?", projectID). + Exec(context.Background()) + + return err +} + +// DisableProjectEncryption disables encryption for a project (but keeps the salt) +func DisableProjectEncryption(projectID string, db *bun.DB) error { + _, err := db.NewUpdate(). + Model((*models.Projects)(nil)). + Set("encryption_enabled = ?", false). + Where("id = ?", projectID). + Exec(context.Background()) + + return err +} + +// RotateProjectEncryptionKey generates a new encryption salt for a project +func RotateProjectEncryptionKey(projectID string, db *bun.DB) (string, error) { + newSalt, err := GenerateProjectSalt() + if err != nil { + return "", err + } + + _, err = db.NewUpdate(). + Model((*models.Projects)(nil)). + Set("encryption_key = ?", newSalt). + Where("id = ?", projectID). + Exec(context.Background()) + + if err != nil { + return "", err + } + + return newSalt, nil +} diff --git a/services/backend/functions/encryption/params.go b/services/backend/functions/encryption/project_params.go similarity index 67% rename from services/backend/functions/encryption/params.go rename to services/backend/functions/encryption/project_params.go index 82da8364..877690bb 100644 --- a/services/backend/functions/encryption/params.go +++ b/services/backend/functions/encryption/project_params.go @@ -1,6 +1,7 @@ package encryption import ( + "context" "crypto/aes" "crypto/cipher" "crypto/rand" @@ -10,22 +11,61 @@ import ( "fmt" "io" + "github.com/uptrace/bun" "github.com/v1Flows/exFlow/services/backend/config" + "github.com/v1Flows/exFlow/services/backend/pkg/models" shared_models "github.com/v1Flows/shared-library/pkg/models" ) -func IsEncrypted(value string) bool { - // Encrypted values should be at least as long as the AES block size - if len(value) < aes.BlockSize*2 { - return false +// getEncryptionKey returns the appropriate encryption key for a project +// Falls back to global config key if project encryption is disabled or key is missing +func getEncryptionKey(projectID string, db *bun.DB) ([]byte, error) { + if projectID == "" { + // Fall back to global config if no project ID provided + return []byte(config.Config.Encryption.Key), nil + } + + var project models.Projects + err := db.NewSelect().Model(&project).Where("id = ?", projectID).Scan(context.Background()) + if err != nil { + // Fall back to global config if project not found + return []byte(config.Config.Encryption.Key), nil + } + + // Use project-specific encryption if enabled and salt exists + if project.EncryptionEnabled && project.EncryptionKey != "" { + // Try to derive key from master secret + salt + masterSecret := config.Config.Encryption.MasterSecret + if masterSecret != "" { + keyBytes, err := DeriveProjectEncryptionKey(project.EncryptionKey, masterSecret) + if err != nil { + // Fall back to global config if key derivation fails + return []byte(config.Config.Encryption.Key), nil + } + return keyBytes, nil + } + + // Legacy: treat stored value as actual key (for backward compatibility) + keyBytes, err := hex.DecodeString(project.EncryptionKey) + if err != nil { + // Fall back to global config if key decode fails + return []byte(config.Config.Encryption.Key), nil + } + return keyBytes, nil } - _, err := hex.DecodeString(value) - return err == nil + // Fall back to global config + return []byte(config.Config.Encryption.Key), nil } -func EncryptParams(actions []shared_models.Action) ([]shared_models.Action, error) { - block, err := aes.NewCipher([]byte(config.Config.Encryption.Key)) +// EncryptParamsWithProject encrypts action params using project-specific encryption +func EncryptParamsWithProject(actions []shared_models.Action, projectID string, db *bun.DB) ([]shared_models.Action, error) { + encryptionKey, err := getEncryptionKey(projectID, db) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(encryptionKey) if err != nil { return nil, err } @@ -108,8 +148,14 @@ func EncryptParams(actions []shared_models.Action) ([]shared_models.Action, erro return actions, nil } -func DecryptParams(actions []shared_models.Action, decryptPasswords bool) ([]shared_models.Action, error) { - block, err := aes.NewCipher([]byte(config.Config.Encryption.Key)) +// DecryptParamsWithProject decrypts action params using project-specific encryption +func DecryptParamsWithProject(actions []shared_models.Action, projectID string, decryptPasswords bool, db *bun.DB) ([]shared_models.Action, error) { + encryptionKey, err := getEncryptionKey(projectID, db) + if err != nil { + return nil, err + } + + block, err := aes.NewCipher(encryptionKey) if err != nil { return nil, err } @@ -217,8 +263,14 @@ func DecryptParams(actions []shared_models.Action, decryptPasswords bool) ([]sha return actions, nil } -func EncryptParam(param shared_models.Params) (shared_models.Params, error) { - block, err := aes.NewCipher([]byte(config.Config.Encryption.Key)) +// EncryptParamWithProject encrypts a single param using project-specific encryption +func EncryptParamWithProject(param shared_models.Params, projectID string, db *bun.DB) (shared_models.Params, error) { + encryptionKey, err := getEncryptionKey(projectID, db) + if err != nil { + return param, err + } + + block, err := aes.NewCipher(encryptionKey) if err != nil { return param, err } @@ -258,8 +310,14 @@ func EncryptParam(param shared_models.Params) (shared_models.Params, error) { return param, nil } -func DecryptString(value string) (string, error) { - block, err := aes.NewCipher([]byte(config.Config.Encryption.Key)) +// DecryptStringWithProject decrypts a string using project-specific encryption +func DecryptStringWithProject(value string, projectID string, db *bun.DB) (string, error) { + encryptionKey, err := getEncryptionKey(projectID, db) + if err != nil { + return "", err + } + + block, err := aes.NewCipher(encryptionKey) if err != nil { return "", err } @@ -291,3 +349,14 @@ func DecryptString(value string) (string, error) { return string(plaintext), nil } + +func IsEncrypted(value string) bool { + decoded, err := hex.DecodeString(value) + if err != nil { + return false + } + + // GCM nonce size is 12 bytes for standard GCM + nonceSize := 12 + return len(decoded) > nonceSize +} diff --git a/services/backend/go.mod b/services/backend/go.mod index 34f550d4..ba854fbb 100644 --- a/services/backend/go.mod +++ b/services/backend/go.mod @@ -73,6 +73,6 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/spf13/viper v1.20.1 github.com/uptrace/bun/dialect/pgdialect v1.2.15 - github.com/v1Flows/shared-library v1.0.25 + github.com/v1Flows/shared-library v1.0.27 golang.org/x/sys v0.35.0 // indirect ) diff --git a/services/backend/go.sum b/services/backend/go.sum index 91587d8f..9bac778a 100644 --- a/services/backend/go.sum +++ b/services/backend/go.sum @@ -131,8 +131,8 @@ github.com/uptrace/bun/extra/bunotel v1.2.15 h1:6KAvKRpH9BC/7n3eMXVgDYLqghHf2H3F github.com/uptrace/bun/extra/bunotel v1.2.15/go.mod h1:qnASdcJVuoEE+13N3Gd8XHi5gwCydt2S1TccJnefH2k= github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2 h1:ZjUj9BLYf9PEqBn8W/OapxhPjVRdC6CsXTdULHsyk5c= github.com/uptrace/opentelemetry-go-extra/otelsql v0.3.2/go.mod h1:O8bHQfyinKwTXKkiKNGmLQS7vRsqRxIQTFZpYpHK3IQ= -github.com/v1Flows/shared-library v1.0.25 h1:Rez0FNvDXdYByx3JAT8/+BXqld2vmvqUz0rPoBxt5UE= -github.com/v1Flows/shared-library v1.0.25/go.mod h1:UVP6m6Nri6JC3L0xS3wkbqGvfQJ5fsYIJx81Gfj1TFw= +github.com/v1Flows/shared-library v1.0.27 h1:BQMZ0hgBMhOHMelygi4Rl7XxriAEKveFarWer9SlN0Q= +github.com/v1Flows/shared-library v1.0.27/go.mod h1:UVP6m6Nri6JC3L0xS3wkbqGvfQJ5fsYIJx81Gfj1TFw= github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= diff --git a/services/backend/handlers/executions/create_step.go b/services/backend/handlers/executions/create_step.go index b374556e..7304d781 100644 --- a/services/backend/handlers/executions/create_step.go +++ b/services/backend/handlers/executions/create_step.go @@ -35,10 +35,17 @@ func CreateStep(context *gin.Context, db *bun.DB) { httperror.InternalServerError(context, "Error fetching flow data", err) return } + // get project data + var project models.Projects + err = db.NewSelect().Model(&project).Where("id = ?", flow.ProjectID).Scan(context) + if err != nil { + httperror.InternalServerError(context, "Error collecting project data from db", err) + return + } // check for encryption - if flow.EncryptExecutions && step.Messages != nil && len(step.Messages) > 0 { - step.Messages, err = encryption.EncryptExecutionStepActionMessage(step.Messages) + if project.EncryptionEnabled && step.Messages != nil && len(step.Messages) > 0 { + step.Messages, err = encryption.EncryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { httperror.InternalServerError(context, "Error encrypting execution step action messages", err) return diff --git a/services/backend/handlers/executions/get_step.go b/services/backend/handlers/executions/get_step.go index d876bb20..3ceef6b9 100644 --- a/services/backend/handlers/executions/get_step.go +++ b/services/backend/handlers/executions/get_step.go @@ -1,10 +1,11 @@ package executions import ( + "net/http" + "github.com/v1Flows/exFlow/services/backend/functions/encryption" "github.com/v1Flows/exFlow/services/backend/functions/httperror" "github.com/v1Flows/exFlow/services/backend/pkg/models" - "net/http" "github.com/gin-gonic/gin" "github.com/uptrace/bun" @@ -21,8 +22,24 @@ func GetStep(context *gin.Context, db *bun.DB) { return } + // get execution data + var execution models.Executions + err = db.NewSelect().Model(&execution).Where("id = ?", executionID).Scan(context) + if err != nil { + httperror.InternalServerError(context, "Error fetching execution data", err) + return + } + + // get flow data + var flow models.Flows + err = db.NewSelect().Model(&flow).Where("id = ?", execution.FlowID).Scan(context) + if err != nil { + httperror.InternalServerError(context, "Error fetching flow data", err) + return + } + if step.Encrypted { - step.Messages, err = encryption.DecryptExecutionStepActionMessage(step.Messages) + step.Messages, err = encryption.DecryptExecutionStepActionMessageWithProject(step.Messages, flow.ProjectID, db) if err != nil { httperror.InternalServerError(context, "Error decrypting execution step action messages", err) return diff --git a/services/backend/handlers/executions/get_steps.go b/services/backend/handlers/executions/get_steps.go index 894f050f..06909ccc 100644 --- a/services/backend/handlers/executions/get_steps.go +++ b/services/backend/handlers/executions/get_steps.go @@ -21,9 +21,25 @@ func GetSteps(context *gin.Context, db *bun.DB) { return } + // get execution data + var execution models.Executions + err = db.NewSelect().Model(&execution).Where("id = ?", executionID).Scan(context) + if err != nil { + httperror.InternalServerError(context, "Error fetching execution data", err) + return + } + + // get flow data + var flow models.Flows + err = db.NewSelect().Model(&flow).Where("id = ?", execution.FlowID).Scan(context) + if err != nil { + httperror.InternalServerError(context, "Error fetching flow data", err) + return + } + for i := range steps { if steps[i].Encrypted { - steps[i].Messages, err = encryption.DecryptExecutionStepActionMessage(steps[i].Messages) + steps[i].Messages, err = encryption.DecryptExecutionStepActionMessageWithProject(steps[i].Messages, flow.ProjectID, db) if err != nil { httperror.InternalServerError(context, "Error decrypting execution step action messages", err) return diff --git a/services/backend/handlers/executions/schedule.go b/services/backend/handlers/executions/schedule.go index c4308999..62853743 100644 --- a/services/backend/handlers/executions/schedule.go +++ b/services/backend/handlers/executions/schedule.go @@ -77,9 +77,17 @@ func ScheduleExecution(context *gin.Context, db *bun.DB) { return } + // get project data + var project models.Projects + err = db.NewSelect().Model(&project).Where("id = ?", flow.ProjectID).Scan(context) + if err != nil { + httperror.InternalServerError(context, "Error collecting project data from db", err) + return + } + // check for encryption - if flow.EncryptExecutions && step.Messages != nil && len(step.Messages) > 0 { - step.Messages, err = encryption.EncryptExecutionStepActionMessage(step.Messages) + if project.EncryptionEnabled && step.Messages != nil && len(step.Messages) > 0 { + step.Messages, err = encryption.EncryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { httperror.InternalServerError(context, "Error encrypting execution step action messages", err) return diff --git a/services/backend/handlers/executions/update_step.go b/services/backend/handlers/executions/update_step.go index 8cf0e867..486883bb 100644 --- a/services/backend/handlers/executions/update_step.go +++ b/services/backend/handlers/executions/update_step.go @@ -48,10 +48,17 @@ func UpdateStep(context *gin.Context, db *bun.DB) { log.Error("Error fetching flow data", err) return } + // get project data + var project models.Projects + err = db.NewSelect().Model(&project).Where("id = ?", flow.ProjectID).Scan(context) + if err != nil { + httperror.InternalServerError(context, "Error collecting project data from db", err) + return + } // check for ecryption and decrypt if needed - if flow.EncryptExecutions && dbStep.Messages != nil && len(dbStep.Messages) > 0 { - dbStep.Messages, err = encryption.DecryptExecutionStepActionMessage(dbStep.Messages) + if project.EncryptionEnabled && dbStep.Messages != nil && len(dbStep.Messages) > 0 { + dbStep.Messages, err = encryption.DecryptExecutionStepActionMessageWithProject(dbStep.Messages, project.ID.String(), db) if err != nil { httperror.InternalServerError(context, "Error decrypting execution step action messages", err) log.Error("Error decrypting execution step action messages", err) @@ -71,8 +78,8 @@ func UpdateStep(context *gin.Context, db *bun.DB) { } // check for ecryption and encrypt if needed - if flow.EncryptExecutions && step.Messages != nil && len(step.Messages) > 0 { - step.Messages, err = encryption.EncryptExecutionStepActionMessage(step.Messages) + if project.EncryptionEnabled && step.Messages != nil && len(step.Messages) > 0 { + step.Messages, err = encryption.EncryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { httperror.InternalServerError(context, "Error encrypting execution step action messages", err) log.Error("Error encrypting execution step action messages", err) diff --git a/services/backend/handlers/flows/add_actions.go b/services/backend/handlers/flows/add_actions.go index a79b266d..10b7f8ec 100644 --- a/services/backend/handlers/flows/add_actions.go +++ b/services/backend/handlers/flows/add_actions.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" - "github.com/v1Flows/exFlow/services/backend/config" "github.com/v1Flows/exFlow/services/backend/functions/encryption" "github.com/v1Flows/exFlow/services/backend/functions/gatekeeper" "github.com/v1Flows/exFlow/services/backend/functions/httperror" @@ -34,6 +33,14 @@ func AddFlowActions(context *gin.Context, db *bun.DB) { return } + // get project data + var project models.Projects + err = db.NewSelect().Model(&project).Where("id = ?", flowDB.ProjectID).Scan(context) + if err != nil { + httperror.InternalServerError(context, "Error collecting project data from db", err) + return + } + // check if user has access to project access, err := gatekeeper.CheckUserProjectAccess(flowDB.ProjectID, context, db) if err != nil { @@ -57,8 +64,8 @@ func AddFlowActions(context *gin.Context, db *bun.DB) { } // encrypt action params - if config.Config.Encryption.Enabled && flowDB.EncryptActionParams { - flow.Actions, err = encryption.EncryptParams(flow.Actions) + if project.EncryptionEnabled { + flow.Actions, err = encryption.EncryptParamsWithProject(flow.Actions, flowDB.ProjectID, db) if err != nil { httperror.InternalServerError(context, "Error encrypting action params", err) fmt.Println(err) diff --git a/services/backend/handlers/flows/add_failure_pipeline_actions.go b/services/backend/handlers/flows/add_failure_pipeline_actions.go index c366d4b4..8cddf496 100644 --- a/services/backend/handlers/flows/add_failure_pipeline_actions.go +++ b/services/backend/handlers/flows/add_failure_pipeline_actions.go @@ -6,7 +6,6 @@ import ( "net/http" log "github.com/sirupsen/logrus" - "github.com/v1Flows/exFlow/services/backend/config" "github.com/v1Flows/exFlow/services/backend/functions/encryption" "github.com/v1Flows/exFlow/services/backend/functions/gatekeeper" "github.com/v1Flows/exFlow/services/backend/functions/httperror" @@ -36,6 +35,14 @@ func AddFlowFailurePipelineActions(context *gin.Context, db *bun.DB) { return } + // get project data + var project models.Projects + err = db.NewSelect().Model(&project).Where("id = ?", flowDB.ProjectID).Scan(context) + if err != nil { + httperror.InternalServerError(context, "Error collecting project data from db", err) + return + } + // check if user has access to project access, err := gatekeeper.CheckUserProjectAccess(flowDB.ProjectID, context, db) if err != nil { @@ -59,8 +66,8 @@ func AddFlowFailurePipelineActions(context *gin.Context, db *bun.DB) { } // encrypt action params - if config.Config.Encryption.Enabled && flowDB.EncryptActionParams { - failurePipeline.Actions, err = encryption.EncryptParams(failurePipeline.Actions) + if project.EncryptionEnabled { + failurePipeline.Actions, err = encryption.EncryptParamsWithProject(failurePipeline.Actions, flowDB.ProjectID, db) if err != nil { httperror.InternalServerError(context, "Error encrypting failure pipeline action params", err) fmt.Println(err) diff --git a/services/backend/handlers/flows/get_flow.go b/services/backend/handlers/flows/get_flow.go index dccc141d..0b3627f8 100644 --- a/services/backend/handlers/flows/get_flow.go +++ b/services/backend/handlers/flows/get_flow.go @@ -4,7 +4,6 @@ import ( "errors" "net/http" - "github.com/v1Flows/exFlow/services/backend/config" "github.com/v1Flows/exFlow/services/backend/functions/auth" "github.com/v1Flows/exFlow/services/backend/functions/encryption" "github.com/v1Flows/exFlow/services/backend/functions/gatekeeper" @@ -26,6 +25,14 @@ func GetFlow(context *gin.Context, db *bun.DB) { return } + // get project data + var project models.Projects + err = db.NewSelect().Model(&project).Where("id = ?", flow.ProjectID).Scan(context) + if err != nil { + httperror.InternalServerError(context, "Error collecting project data from db", err) + return + } + // check if user has access to project access, err := gatekeeper.CheckUserProjectAccess(flow.ProjectID, context, db) if err != nil { @@ -52,8 +59,8 @@ func GetFlow(context *gin.Context, db *bun.DB) { decryptPasswords = true } - if config.Config.Encryption.Enabled && flow.EncryptActionParams && len(flow.Actions) > 0 { - flow.Actions, err = encryption.DecryptParams(flow.Actions, decryptPasswords) + if project.EncryptionEnabled && len(flow.Actions) > 0 { + flow.Actions, err = encryption.DecryptParamsWithProject(flow.Actions, flow.ProjectID, decryptPasswords, db) if err != nil { httperror.InternalServerError(context, "Error decrypting action params", err) return @@ -62,7 +69,7 @@ func GetFlow(context *gin.Context, db *bun.DB) { // decrypt failure pipeline actions for i, pipeline := range flow.FailurePipelines { if pipeline.Actions != nil { - flow.FailurePipelines[i].Actions, err = encryption.DecryptParams(pipeline.Actions, decryptPasswords) + flow.FailurePipelines[i].Actions, err = encryption.DecryptParamsWithProject(pipeline.Actions, flow.ProjectID, decryptPasswords, db) if err != nil { httperror.InternalServerError(context, "Error decrypting action params", err) return diff --git a/services/backend/handlers/flows/get_flows.go b/services/backend/handlers/flows/get_flows.go index 1829d0b9..8da8d422 100644 --- a/services/backend/handlers/flows/get_flows.go +++ b/services/backend/handlers/flows/get_flows.go @@ -3,7 +3,6 @@ package flows import ( "net/http" - "github.com/v1Flows/exFlow/services/backend/config" "github.com/v1Flows/exFlow/services/backend/functions/auth" "github.com/v1Flows/exFlow/services/backend/functions/encryption" "github.com/v1Flows/exFlow/services/backend/functions/httperror" @@ -43,29 +42,36 @@ func GetFlows(context *gin.Context, db *bun.DB) { decryptPasswords = true } - if config.Config.Encryption.Enabled && len(flows) > 0 { - for i, flow := range flows { - if flow.EncryptActionParams && len(flow.Actions) > 0 { - flow.Actions, err = encryption.DecryptParams(flow.Actions, decryptPasswords) - if err != nil { - httperror.InternalServerError(context, "Error decrypting action params", err) - return - } + for i, flow := range flows { - flows[i].Actions = flow.Actions + // get project data + var project models.Projects + err = db.NewSelect().Model(&project).Where("id = ?", flow.ProjectID).Scan(context) + if err != nil { + httperror.InternalServerError(context, "Error collecting project data from db", err) + return + } - // decrypt failure pipeline actions - for i, pipeline := range flow.FailurePipelines { - if pipeline.Actions != nil { - flow.FailurePipelines[i].Actions, err = encryption.DecryptParams(pipeline.Actions, decryptPasswords) - if err != nil { - httperror.InternalServerError(context, "Error decrypting action params", err) - return - } - } + if project.EncryptionEnabled && len(flow.Actions) > 0 { + flow.Actions, err = encryption.DecryptParamsWithProject(flow.Actions, flow.ProjectID, decryptPasswords, db) + if err != nil { + httperror.InternalServerError(context, "Error decrypting action params", err) + return + } - flows[i].FailurePipelines = flow.FailurePipelines + flows[i].Actions = flow.Actions + + // decrypt failure pipeline actions + for i, pipeline := range flow.FailurePipelines { + if pipeline.Actions != nil { + flow.FailurePipelines[i].Actions, err = encryption.DecryptParamsWithProject(pipeline.Actions, flow.ProjectID, decryptPasswords, db) + if err != nil { + httperror.InternalServerError(context, "Error decrypting action params", err) + return + } } + + flows[i].FailurePipelines = flow.FailurePipelines } } } diff --git a/services/backend/handlers/flows/start_execution.go b/services/backend/handlers/flows/start_execution.go index dea1c8d1..ff41e9d4 100644 --- a/services/backend/handlers/flows/start_execution.go +++ b/services/backend/handlers/flows/start_execution.go @@ -27,6 +27,14 @@ func StartExecution(context *gin.Context, db *bun.DB) { return } + // get project data + var project models.Projects + err = db.NewSelect().Model(&project).Where("id = ?", flow.ProjectID).Scan(context) + if err != nil { + httperror.InternalServerError(context, "Error collecting project data from db", err) + return + } + // check auth token type tokenType, err := auth.GetTypeFromToken(context.GetHeader("Authorization")) if err != nil { @@ -74,8 +82,8 @@ func StartExecution(context *gin.Context, db *bun.DB) { } // check for encryption - if flow.EncryptExecutions && step.Messages != nil && len(step.Messages) > 0 { - step.Messages, err = encryption.EncryptExecutionStepActionMessage(step.Messages) + if project.EncryptionEnabled && step.Messages != nil && len(step.Messages) > 0 { + step.Messages, err = encryption.EncryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { httperror.InternalServerError(context, "Error encrypting execution step action messages", err) return diff --git a/services/backend/handlers/flows/update.go b/services/backend/handlers/flows/update.go index 092d3c08..99e5faf5 100644 --- a/services/backend/handlers/flows/update.go +++ b/services/backend/handlers/flows/update.go @@ -71,12 +71,6 @@ func UpdateFlow(context *gin.Context, db *bun.DB) { if flow.RunnerID != flowDB.RunnerID { columns = append(columns, "runner_id") } - if flow.EncryptActionParams != flowDB.EncryptActionParams { - columns = append(columns, "encrypt_action_params") - } - if flow.EncryptExecutions != flowDB.EncryptExecutions { - columns = append(columns, "encrypt_executions") - } if flow.ScheduleEveryValue != flowDB.ScheduleEveryValue { columns = append(columns, "schedule_every_value") } diff --git a/services/backend/handlers/flows/update_actions.go b/services/backend/handlers/flows/update_actions.go index 2b8fc67c..719d9ea1 100644 --- a/services/backend/handlers/flows/update_actions.go +++ b/services/backend/handlers/flows/update_actions.go @@ -4,7 +4,6 @@ import ( "errors" "net/http" - "github.com/v1Flows/exFlow/services/backend/config" "github.com/v1Flows/exFlow/services/backend/functions/encryption" "github.com/v1Flows/exFlow/services/backend/functions/gatekeeper" "github.com/v1Flows/exFlow/services/backend/functions/httperror" @@ -34,6 +33,14 @@ func UpdateFlowActions(context *gin.Context, db *bun.DB) { return } + // get project data + var project models.Projects + err = db.NewSelect().Model(&project).Where("id = ?", flowDB.ProjectID).Scan(context) + if err != nil { + httperror.InternalServerError(context, "Error collecting project data from db", err) + return + } + // check if user has access to project access, err := gatekeeper.CheckUserProjectAccess(flowDB.ProjectID, context, db) if err != nil { @@ -57,8 +64,8 @@ func UpdateFlowActions(context *gin.Context, db *bun.DB) { } // encrypt action params - if config.Config.Encryption.Enabled && flowDB.EncryptActionParams { - flow.Actions, err = encryption.EncryptParams(flow.Actions) + if project.EncryptionEnabled { + flow.Actions, err = encryption.EncryptParamsWithProject(flow.Actions, project.ID.String(), db) if err != nil { httperror.InternalServerError(context, "Error encrypting action params", err) return diff --git a/services/backend/handlers/flows/update_actions_details.go b/services/backend/handlers/flows/update_actions_details.go index 27106b04..3e77fe70 100644 --- a/services/backend/handlers/flows/update_actions_details.go +++ b/services/backend/handlers/flows/update_actions_details.go @@ -4,7 +4,6 @@ import ( "errors" "net/http" - "github.com/v1Flows/exFlow/services/backend/config" "github.com/v1Flows/exFlow/services/backend/functions/encryption" "github.com/v1Flows/exFlow/services/backend/functions/gatekeeper" "github.com/v1Flows/exFlow/services/backend/functions/httperror" @@ -34,6 +33,14 @@ func UpdateFlowActionsDetails(context *gin.Context, db *bun.DB) { return } + // get project data + var project models.Projects + err = db.NewSelect().Model(&project).Where("id = ?", flowDB.ProjectID).Scan(context) + if err != nil { + httperror.InternalServerError(context, "Error collecting project data from db", err) + return + } + // check if user has access to project access, err := gatekeeper.CheckUserProjectAccess(flowDB.ProjectID, context, db) if err != nil { @@ -56,23 +63,17 @@ func UpdateFlowActionsDetails(context *gin.Context, db *bun.DB) { return } - if (!flowDB.EncryptActionParams && flow.EncryptActionParams) && config.Config.Encryption.Enabled { - flow.Actions, err = encryption.EncryptParams(flowDB.Actions) + if project.EncryptionEnabled { + flow.Actions, err = encryption.EncryptParamsWithProject(flowDB.Actions, flowDB.ProjectID, db) if err != nil { httperror.InternalServerError(context, "Error encrypting action params", err) return } - } else if flowDB.EncryptActionParams && !flow.EncryptActionParams && config.Config.Encryption.Enabled { - flow.Actions, err = encryption.DecryptParams(flowDB.Actions, true) - if err != nil { - httperror.InternalServerError(context, "Error decrypting action params", err) - return - } } else { flow.Actions = flowDB.Actions } - _, err = db.NewUpdate().Model(&flow).Column("encrypt_action_params", "exec_parallel", "patterns", "actions").Where("id = ?", flowID).Exec(context) + _, err = db.NewUpdate().Model(&flow).Column("exec_parallel", "patterns", "actions").Where("id = ?", flowID).Exec(context) if err != nil { httperror.InternalServerError(context, "Error updating actions details on db", err) return diff --git a/services/backend/handlers/flows/update_failure_pipeline.go b/services/backend/handlers/flows/update_failure_pipeline.go index bdff99b4..6a2516ef 100644 --- a/services/backend/handlers/flows/update_failure_pipeline.go +++ b/services/backend/handlers/flows/update_failure_pipeline.go @@ -5,7 +5,6 @@ import ( "net/http" "time" - "github.com/v1Flows/exFlow/services/backend/config" "github.com/v1Flows/exFlow/services/backend/functions/encryption" "github.com/v1Flows/exFlow/services/backend/functions/gatekeeper" "github.com/v1Flows/exFlow/services/backend/functions/httperror" @@ -35,6 +34,14 @@ func UpdateFlowFailurePipelines(context *gin.Context, db *bun.DB) { return } + // get project data + var project models.Projects + err = db.NewSelect().Model(&project).Where("id = ?", flowDB.ProjectID).Scan(context) + if err != nil { + httperror.InternalServerError(context, "Error collecting project data from db", err) + return + } + // check if user has access to project access, err := gatekeeper.CheckUserProjectAccess(flow.ProjectID, context, db) if err != nil { @@ -60,10 +67,10 @@ func UpdateFlowFailurePipelines(context *gin.Context, db *bun.DB) { flow.UpdatedAt = time.Now() // encrypt the actions for each failure pipeline - if config.Config.Encryption.Enabled && flowDB.EncryptActionParams { + if project.EncryptionEnabled { for i := range flow.FailurePipelines { if flow.FailurePipelines[i].Actions != nil { - flow.FailurePipelines[i].Actions, err = encryption.EncryptParams(flow.FailurePipelines[i].Actions) + flow.FailurePipelines[i].Actions, err = encryption.EncryptParamsWithProject(flow.FailurePipelines[i].Actions, project.ID.String(), db) if err != nil { httperror.InternalServerError(context, "Error encrypting actions", err) return diff --git a/services/backend/handlers/flows/update_failure_pipeline_actions.go b/services/backend/handlers/flows/update_failure_pipeline_actions.go index a3a801f5..979db7fe 100644 --- a/services/backend/handlers/flows/update_failure_pipeline_actions.go +++ b/services/backend/handlers/flows/update_failure_pipeline_actions.go @@ -4,7 +4,6 @@ import ( "errors" "net/http" - "github.com/v1Flows/exFlow/services/backend/config" "github.com/v1Flows/exFlow/services/backend/functions/encryption" "github.com/v1Flows/exFlow/services/backend/functions/gatekeeper" "github.com/v1Flows/exFlow/services/backend/functions/httperror" @@ -36,6 +35,14 @@ func UpdateFlowFailurePipelineActions(context *gin.Context, db *bun.DB) { return } + // get project data + var project models.Projects + err = db.NewSelect().Model(&project).Where("id = ?", flowDB.ProjectID).Scan(context) + if err != nil { + httperror.InternalServerError(context, "Error collecting project data from db", err) + return + } + // check if user has access to project access, err := gatekeeper.CheckUserProjectAccess(flowDB.ProjectID, context, db) if err != nil { @@ -59,8 +66,8 @@ func UpdateFlowFailurePipelineActions(context *gin.Context, db *bun.DB) { } // encrypt action params - if config.Config.Encryption.Enabled && flowDB.EncryptActionParams { - failurePipeline.Actions, err = encryption.EncryptParams(failurePipeline.Actions) + if project.EncryptionEnabled { + failurePipeline.Actions, err = encryption.EncryptParamsWithProject(failurePipeline.Actions, project.ID.String(), db) if err != nil { httperror.InternalServerError(context, "Error encrypting action params", err) return diff --git a/services/backend/handlers/projects/create.go b/services/backend/handlers/projects/create.go index 1f6d3eea..5dbe27c3 100644 --- a/services/backend/handlers/projects/create.go +++ b/services/backend/handlers/projects/create.go @@ -8,6 +8,7 @@ import ( "time" "github.com/v1Flows/exFlow/services/backend/functions/auth" + "github.com/v1Flows/exFlow/services/backend/functions/encryption" "github.com/v1Flows/exFlow/services/backend/functions/httperror" functions_runner "github.com/v1Flows/exFlow/services/backend/functions/runner" "github.com/v1Flows/exFlow/services/backend/pkg/models" @@ -55,7 +56,16 @@ func CreateProject(context *gin.Context, db *bun.DB) { return } - _, err = db.NewInsert().Model(&project).Column("id", "name", "description", "shared_runners", "icon", "color", "runner_auto_join_token").Exec(context) + // Generate encryption salt for the new project + encryptionSalt, err := encryption.GenerateProjectSalt() + if err != nil { + httperror.InternalServerError(context, "Error generating encryption salt", err) + return + } + project.EncryptionKey = encryptionSalt + project.EncryptionEnabled = true + + _, err = db.NewInsert().Model(&project).Column("id", "name", "description", "shared_runners", "icon", "color", "runner_auto_join_token", "encryption_key", "encryption_enabled").Exec(context) if err != nil { log.Error(err) httperror.InternalServerError(context, "Error creating project on db", err) diff --git a/services/backend/handlers/projects/encryption.go b/services/backend/handlers/projects/encryption.go new file mode 100644 index 00000000..f8ee5a43 --- /dev/null +++ b/services/backend/handlers/projects/encryption.go @@ -0,0 +1,152 @@ +package projects + +import ( + "errors" + "net/http" + + "github.com/v1Flows/exFlow/services/backend/functions/encryption" + "github.com/v1Flows/exFlow/services/backend/functions/gatekeeper" + "github.com/v1Flows/exFlow/services/backend/functions/httperror" + "github.com/v1Flows/exFlow/services/backend/pkg/models" + + "github.com/gin-gonic/gin" + "github.com/uptrace/bun" +) + +// GetProjectEncryptionStatus returns the encryption status for a project +func GetProjectEncryptionStatus(context *gin.Context, db *bun.DB) { + projectID := context.Param("projectID") + + // check if user has access to project + access, err := gatekeeper.CheckUserProjectAccess(projectID, context, db) + if err != nil { + httperror.InternalServerError(context, "Error checking for project access", err) + return + } + if !access { + httperror.Unauthorized(context, "You do not have access to this project", errors.New("you do not have access to this project")) + return + } + + var project models.Projects + err = db.NewSelect().Model(&project).Where("id = ?", projectID).Scan(context) + if err != nil { + httperror.InternalServerError(context, "Error receiving project data from db", err) + return + } + + response := gin.H{ + "encryption_enabled": project.EncryptionEnabled, + "has_encryption_salt": project.EncryptionKey != "", + } + + context.JSON(http.StatusOK, response) +} + +// EnableProjectEncryption enables encryption for a project +func EnableProjectEncryption(context *gin.Context, db *bun.DB) { + projectID := context.Param("projectID") + + // check if user has access to project + access, err := gatekeeper.CheckUserProjectAccess(projectID, context, db) + if err != nil { + httperror.InternalServerError(context, "Error checking for project access", err) + return + } + if !access { + httperror.Unauthorized(context, "You do not have access to this project", errors.New("you do not have access to this project")) + return + } + + // check the requestors role in project (only owners and editors can manage encryption) + canModify, err := gatekeeper.CheckRequestUserProjectModifyRole(projectID, context, db) + if err != nil { + httperror.InternalServerError(context, "Error checking your user permissions on project", err) + return + } + if !canModify { + httperror.Unauthorized(context, "You are not allowed to make modifications on this project", errors.New("unauthorized")) + return + } + + err = encryption.EnableProjectEncryption(projectID, db) + if err != nil { + httperror.InternalServerError(context, "Error enabling project encryption", err) + return + } + + context.JSON(http.StatusOK, gin.H{"result": "success", "message": "Project encryption enabled"}) +} + +// DisableProjectEncryption disables encryption for a project +func DisableProjectEncryption(context *gin.Context, db *bun.DB) { + projectID := context.Param("projectID") + + // check if user has access to project + access, err := gatekeeper.CheckUserProjectAccess(projectID, context, db) + if err != nil { + httperror.InternalServerError(context, "Error checking for project access", err) + return + } + if !access { + httperror.Unauthorized(context, "You do not have access to this project", errors.New("you do not have access to this project")) + return + } + + // check the requestors role in project (only owners and editors can manage encryption) + canModify, err := gatekeeper.CheckRequestUserProjectModifyRole(projectID, context, db) + if err != nil { + httperror.InternalServerError(context, "Error checking your user permissions on project", err) + return + } + if !canModify { + httperror.Unauthorized(context, "You are not allowed to make modifications on this project", errors.New("unauthorized")) + return + } + + err = encryption.DisableProjectEncryption(projectID, db) + if err != nil { + httperror.InternalServerError(context, "Error disabling project encryption", err) + return + } + + context.JSON(http.StatusOK, gin.H{"result": "success", "message": "Project encryption disabled"}) +} + +// RotateProjectEncryptionKey generates a new encryption key for a project +func RotateProjectEncryptionKey(context *gin.Context, db *bun.DB) { + projectID := context.Param("projectID") + + // check if user has access to project + access, err := gatekeeper.CheckUserProjectAccess(projectID, context, db) + if err != nil { + httperror.InternalServerError(context, "Error checking for project access", err) + return + } + if !access { + httperror.Unauthorized(context, "You do not have access to this project", errors.New("you do not have access to this project")) + return + } + + // check the requestors role in project (only owners can rotate encryption keys) + canModify, err := gatekeeper.CheckRequestUserProjectModifyRole(projectID, context, db) + if err != nil { + httperror.InternalServerError(context, "Error checking your user permissions on project", err) + return + } + if !canModify { + httperror.Unauthorized(context, "You are not allowed to make modifications on this project", errors.New("unauthorized")) + return + } + + _, err = encryption.RotateProjectEncryptionKey(projectID, db) + if err != nil { + httperror.InternalServerError(context, "Error rotating project encryption key", err) + return + } + + context.JSON(http.StatusOK, gin.H{ + "result": "success", + "message": "Project encryption salt rotated successfully. Existing encrypted data will continue to work with the old salt until re-encrypted.", + }) +} diff --git a/services/backend/main.go b/services/backend/main.go index 7e64c6ef..3e7a0909 100644 --- a/services/backend/main.go +++ b/services/backend/main.go @@ -6,13 +6,14 @@ import ( "github.com/v1Flows/exFlow/services/backend/config" "github.com/v1Flows/exFlow/services/backend/database" "github.com/v1Flows/exFlow/services/backend/functions/background_checks" + "github.com/v1Flows/exFlow/services/backend/functions/encryption" "github.com/v1Flows/exFlow/services/backend/router" "github.com/alecthomas/kingpin/v2" log "github.com/sirupsen/logrus" ) -const version string = "1.5.2" +const version string = "2.0.0" var ( configFile = kingpin.Flag("config", "Config file").Short('c').Default("config.yaml").String() @@ -21,15 +22,16 @@ var ( func logging(logLevel string) { logLevel = strings.ToLower(logLevel) - if logLevel == "info" { + switch logLevel { + case "info": log.SetLevel(log.InfoLevel) - } else if logLevel == "warn" { + case "warn": log.SetLevel(log.WarnLevel) - } else if logLevel == "error" { + case "error": log.SetLevel(log.ErrorLevel) - } else if logLevel == "debug" { + case "debug": log.SetLevel(log.DebugLevel) - } else { + default: log.SetLevel(log.InfoLevel) } } @@ -57,6 +59,11 @@ func main() { log.Fatal("Failed to connect to the database") } + err = encryption.MigrateProjectsEncryption(cfg.Encryption.Key, db) + if err != nil { + log.Fatal("Failed to migrate projects: ", err) + } + go background_checks.Init(db) router.StartRouter(db, cfg.Port) } diff --git a/services/backend/pkg/models/projects.go b/services/backend/pkg/models/projects.go index c99a3ced..09192d6f 100644 --- a/services/backend/pkg/models/projects.go +++ b/services/backend/pkg/models/projects.go @@ -22,6 +22,8 @@ type Projects struct { EnableAutoRunners bool `bun:"enable_auto_runners,type:bool,default:false" json:"enable_auto_runners"` DisableRunnerJoin bool `bun:"disable_runner_join,type:bool,default:false" json:"disable_runner_join"` RunnerAutoJoinToken string `bun:"runner_auto_join_token,type:text,notnull" json:"runner_auto_join_token"` + EncryptionKey string `bun:"encryption_key,type:text,default:''" json:"encryption_key"` + EncryptionEnabled bool `bun:"encryption_enabled,type:bool,default:true" json:"encryption_enabled"` } type ProjectsWithMembers struct { diff --git a/services/backend/pkg/models/settings.go b/services/backend/pkg/models/settings.go index f3234cb5..31311ed9 100644 --- a/services/backend/pkg/models/settings.go +++ b/services/backend/pkg/models/settings.go @@ -20,4 +20,5 @@ type Settings struct { AllowSharedRunnerAutoJoin bool `bun:"allow_shared_runner_auto_join,type:bool,default:true" json:"allow_shared_runner_auto_join"` AllowSharedRunnerJoin bool `bun:"allow_shared_runner_join,type:bool,default:true" json:"allow_shared_runner_join"` SharedRunnerAutoJoinToken string `bun:"shared_runner_auto_join_token,type:text,default:''" json:"shared_runner_auto_join_token"` + NewEncryptionMigrated bool `bun:"new_encryption_migrated,type:bool,default:false" json:"new_encryption_migrated"` } diff --git a/services/backend/router/projects.go b/services/backend/router/projects.go index b04c5ab8..2d9e8197 100644 --- a/services/backend/router/projects.go +++ b/services/backend/router/projects.go @@ -80,5 +80,19 @@ func Projects(router *gin.RouterGroup, db *bun.DB) { project.PUT("/:projectID/transfer_ownership", func(c *gin.Context) { projects.TransferOwnership(c, db) }) + + // encryption management + project.GET("/:projectID/encryption", func(c *gin.Context) { + projects.GetProjectEncryptionStatus(c, db) + }) + project.PUT("/:projectID/encryption/enable", func(c *gin.Context) { + projects.EnableProjectEncryption(c, db) + }) + project.PUT("/:projectID/encryption/disable", func(c *gin.Context) { + projects.DisableProjectEncryption(c, db) + }) + project.PUT("/:projectID/encryption/rotate-key", func(c *gin.Context) { + projects.RotateProjectEncryptionKey(c, db) + }) } } diff --git a/services/frontend/components/projects/project.tsx b/services/frontend/components/projects/project.tsx index 0d1096b9..bc490c27 100644 --- a/services/frontend/components/projects/project.tsx +++ b/services/frontend/components/projects/project.tsx @@ -79,7 +79,7 @@ export default function Project({ )}
-
+
@@ -166,6 +166,25 @@ export default function Project({
+
+ + +
+
+ +
+
+ {project.encryption_enabled ? ( +

Enabled

+ ) : ( +

Disabled

+ )} +

Encryption

+
+
+
+
+
From 0c21ca56b01fa83355a34343a07c5c4d7b161579 Mon Sep 17 00:00:00 2001 From: Justin Neubert Date: Mon, 18 Aug 2025 15:06:40 +0200 Subject: [PATCH 03/20] refactor: Remove encryption settings from flow modals and related functions --- ENCRYPTION_SECURITY.md | 15 ----- .../checkForFlowActionUpdates.go | 1 + .../checkHangingExecutionSteps.go | 7 ++- .../checkHangingExecutions.go | 11 +++- .../checkScheduledExecutions.go | 13 +++- .../components/flows/flow/settings.tsx | 61 ------------------- services/frontend/components/flows/list.tsx | 2 +- .../frontend/components/modals/flows/copy.tsx | 39 ------------ .../components/modals/flows/create.tsx | 37 ----------- .../frontend/components/modals/flows/edit.tsx | 2 - .../frontend/lib/fetch/flow/POST/CopyFlow.ts | 4 -- .../lib/fetch/flow/POST/CreateFlow.ts | 4 -- .../frontend/lib/fetch/flow/PUT/UpdateFlow.ts | 4 -- 13 files changed, 29 insertions(+), 171 deletions(-) diff --git a/ENCRYPTION_SECURITY.md b/ENCRYPTION_SECURITY.md index 34692939..4fda6874 100644 --- a/ENCRYPTION_SECURITY.md +++ b/ENCRYPTION_SECURITY.md @@ -31,15 +31,6 @@ encryption: key: "legacy-key-for-backward-compatibility" ``` -### Option 3: External Key Management (For Enterprise) -```yaml -# For advanced setups, integrate with: -# - AWS KMS -# - HashiCorp Vault -# - Azure Key Vault -# - Google Cloud KMS -``` - ## Master Secret Requirements - **Length**: Minimum 32 characters, recommended 64+ characters @@ -116,10 +107,4 @@ The system maintains backward compatibility: ```go // Environment variable masterSecret := os.Getenv("EXFLOW_ENCRYPTION_MASTER_SECRET") - -// Or AWS Secrets Manager -masterSecret := getFromAWSSecretsManager("exflow/encryption/master-secret") - -// Or HashiCorp Vault -masterSecret := getFromVault("secret/exflow/encryption/master-secret") ``` diff --git a/services/backend/functions/background_checks/checkForFlowActionUpdates.go b/services/backend/functions/background_checks/checkForFlowActionUpdates.go index 1cbba7e6..598b5376 100644 --- a/services/backend/functions/background_checks/checkForFlowActionUpdates.go +++ b/services/backend/functions/background_checks/checkForFlowActionUpdates.go @@ -69,6 +69,7 @@ func processFlowsForProject(db *bun.DB, context context.Context, projectID strin _, err := db.NewUpdate().Model(&updatedFlow).Where("id = ?", updatedFlow.ID).Set("failure_pipelines = ?, actions = ?", updatedFlow.FailurePipelines, updatedFlow.Actions).Exec(context) if err != nil { log.Error("Bot: Error updating flow actions. ", err) + continue } } } diff --git a/services/backend/functions/background_checks/checkHangingExecutionSteps.go b/services/backend/functions/background_checks/checkHangingExecutionSteps.go index a8b69eba..d4d70062 100644 --- a/services/backend/functions/background_checks/checkHangingExecutionSteps.go +++ b/services/backend/functions/background_checks/checkHangingExecutionSteps.go @@ -46,7 +46,8 @@ func checkHangingExecutionSteps(db *bun.DB) { var project models.Projects err = db.NewSelect().Model(&project).Where("id = ?", flow.ProjectID).Scan(context) if err != nil { - return + log.Error("Bot: Error getting project data for flow ", flow.ID, err) + continue } // if the execution is finished, let the step fail @@ -56,6 +57,7 @@ func checkHangingExecutionSteps(db *bun.DB) { step.Messages, err = encryption.DecryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { log.Error("Bot: Error encrypting execution step action messages", err) + continue } step.Encrypted = true @@ -79,6 +81,7 @@ func checkHangingExecutionSteps(db *bun.DB) { step.Messages, err = encryption.EncryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { log.Error("Bot: Error encrypting execution step action messages", err) + continue } step.Encrypted = true @@ -87,6 +90,7 @@ func checkHangingExecutionSteps(db *bun.DB) { _, err := db.NewUpdate().Model(&step).Column("status", "encrypted", "messages", "finished_at").Where("id = ?", step.ID).Exec(context) if err != nil { log.Error("Bot: Error updating step", err) + continue } // set execution status to error if it is not already set @@ -100,6 +104,7 @@ func checkHangingExecutionSteps(db *bun.DB) { _, err := db.NewUpdate().Model(&execution).Column("status", "finished_at").Where("id = ?", execution.ID).Exec(context) if err != nil { log.Error("Bot: Error updating execution status to error", err) + continue } } continue diff --git a/services/backend/functions/background_checks/checkHangingExecutions.go b/services/backend/functions/background_checks/checkHangingExecutions.go index e3c94863..8906869b 100644 --- a/services/backend/functions/background_checks/checkHangingExecutions.go +++ b/services/backend/functions/background_checks/checkHangingExecutions.go @@ -36,13 +36,15 @@ func checkHangingExecutions(db *bun.DB) { err = db.NewSelect().Model(&flow).Where("id = ?", execution.FlowID).Scan(context) if err != nil { log.Error("Bot: Error getting flow data", err) + continue } // get project data var project models.Projects err = db.NewSelect().Model(&project).Where("id = ?", flow.ProjectID).Scan(context) if err != nil { - return + log.Error("Bot: Error getting project data", err) + continue } step := shared_models.ExecutionSteps{ @@ -79,6 +81,7 @@ func checkHangingExecutions(db *bun.DB) { step.Messages, err = encryption.EncryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { log.Error("Bot: Error encrypting execution step action messages", err) + continue } step.Encrypted = true @@ -87,17 +90,20 @@ func checkHangingExecutions(db *bun.DB) { _, err := db.NewInsert().Model(&step).Exec(context) if err != nil { log.Error("Bot: Error adding error step", err) + continue } _, err = db.NewUpdate().Model(&execution).Set("status = 'error'").Set("finished_at = ?", time.Now()).Where("id = ?", execution.ID).Exec(context) if err != nil { log.Error("Bot: Error updating execution", err) + continue } var steps []models.ExecutionSteps err = db.NewSelect().Model(&steps).Where("execution_id = ?", execution.ID).Scan(context) if err != nil { log.Error("Bot: Error getting steps for execution", err) + continue } // mark all steps as canceled if they are not finished @@ -114,6 +120,7 @@ func checkHangingExecutions(db *bun.DB) { step.Messages, err = encryption.DecryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { log.Error("Bot: Error encrypting execution step action messages", err) + continue } step.Encrypted = true @@ -135,6 +142,7 @@ func checkHangingExecutions(db *bun.DB) { step.Messages, err = encryption.EncryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { log.Error("Bot: Error encrypting execution step action messages", err) + continue } step.Encrypted = true @@ -143,6 +151,7 @@ func checkHangingExecutions(db *bun.DB) { _, err := db.NewUpdate().Model(&step).Column("status", "encrypted", "messages", "started_at", "finished_at", "canceled_at", "canceled_by").Where("id = ?", step.ID).Exec(context) if err != nil { log.Error("Bot: Error updating step", err) + continue } } } diff --git a/services/backend/functions/background_checks/checkScheduledExecutions.go b/services/backend/functions/background_checks/checkScheduledExecutions.go index b36fe007..10597a16 100644 --- a/services/backend/functions/background_checks/checkScheduledExecutions.go +++ b/services/backend/functions/background_checks/checkScheduledExecutions.go @@ -31,13 +31,15 @@ func checkScheduledExecutions(db *bun.DB) { err = db.NewSelect().Model(&flow).Where("id = ?", execution.FlowID).Scan(context) if err != nil { log.Error("Bot: Error getting flow data", err) + continue } // get project data var project models.Projects err = db.NewSelect().Model(&project).Where("id = ?", flow.ProjectID).Scan(context) if err != nil { - return + log.Error("Bot: Error getting project data", err) + continue } // update the scheduled step to success @@ -45,6 +47,7 @@ func checkScheduledExecutions(db *bun.DB) { err = db.NewSelect().Model(&steps).Where("execution_id = ?", execution.ID).Scan(context) if err != nil { log.Error("Bot: Error getting steps for execution", err) + continue } // mark all steps as canceled if they are not finished @@ -57,7 +60,8 @@ func checkScheduledExecutions(db *bun.DB) { if project.EncryptionEnabled && step.Messages != nil && len(step.Messages) > 0 { step.Messages, err = encryption.DecryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { - log.Error("Bot: Error encrypting execution step action messages", err) + log.Error("Bot: Error decrypting execution step action messages", err) + continue } step.Encrypted = true @@ -79,6 +83,7 @@ func checkScheduledExecutions(db *bun.DB) { step.Messages, err = encryption.EncryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { log.Error("Bot: Error encrypting execution step action messages", err) + continue } step.Encrypted = true @@ -87,6 +92,7 @@ func checkScheduledExecutions(db *bun.DB) { _, err = db.NewUpdate().Model(&step).Set("status = ?, finished_at = ?, messages = ?", step.Status, step.FinishedAt, step.Messages).Where("id = ?", step.ID).Exec(context) if err != nil { log.Error("Bot: Error updating step", err) + continue } // create execution step which tells that the execution is registerd and waiting for runner to pick it up @@ -117,6 +123,7 @@ func checkScheduledExecutions(db *bun.DB) { step.Messages, err = encryption.EncryptExecutionStepActionMessageWithProject(step.Messages, project.ID.String(), db) if err != nil { log.Error("Bot: Error encrypting execution step action messages", err) + continue } step.Encrypted = true @@ -125,6 +132,7 @@ func checkScheduledExecutions(db *bun.DB) { _, err = db.NewInsert().Model(&step).Exec(context) if err != nil { log.Error("Bot: Error adding error step", err) + continue } } } @@ -132,6 +140,7 @@ func checkScheduledExecutions(db *bun.DB) { _, err = db.NewUpdate().Model(&execution).Set("status = 'pending'").Where("id = ?", execution.ID).Exec(context) if err != nil { log.Error("Bot: Error updating execution", err) + continue } } } diff --git a/services/frontend/components/flows/flow/settings.tsx b/services/frontend/components/flows/flow/settings.tsx index a2481877..8a37eaf4 100644 --- a/services/frontend/components/flows/flow/settings.tsx +++ b/services/frontend/components/flows/flow/settings.tsx @@ -7,7 +7,6 @@ import { Select, SelectItem, Spacer, - Switch, } from "@heroui/react"; import { useRouter } from "next/navigation"; import { useState } from "react"; @@ -31,12 +30,6 @@ export default function FlowSettings({ const [failurePipelineID, setFailurePipelineID] = useState( flow.failure_pipeline_id, ); - const [encryptExecutions, setEncryptExecutions] = useState( - flow.encrypt_executions, - ); - const [encryptActionParams, setEncryptActionParams] = useState( - flow.encrypt_action_params, - ); const [scheduleEveryValue, setScheduleEveryValue] = useState( flow.schedule_every_value, ); @@ -55,8 +48,6 @@ export default function FlowSettings({ flow.project_id, flow.folder_id, flow.runner_id, - encryptExecutions, - encryptActionParams, execParallel, failurePipelineID, scheduleEveryValue, @@ -212,58 +203,6 @@ export default function FlowSettings({
-
-

Encryption

-
- - -
-
-

Action Parameters

-

- The parameters of actions will be encrypted stored on the - db. -

-
- { - setEncryptActionParams(value); - }} - /> -
-
-
- - - -
-
-

Executions

-

- All execution action messages will be stored encrypted on - the db -

-
- { - setEncryptExecutions(value); - }} - /> -
-
-
-
-
@@ -93,7 +106,11 @@ export default function FlowHeading({ Edit - + {/* Mobile */} @@ -112,25 +129,15 @@ export default function FlowHeading({ color="primary" startContent={} variant="solid" - onPress={() => { - APIStartExecution(flow.id) - .then(() => { - addToast({ - title: "Execution Started", - color: "success", - }); - }) - .catch((err) => { - addToast({ - title: "Execution start failed", - description: err.message, - color: "danger", - }); - }); - }} + onPress={handleExecuteFlow} /> - + + ); +} diff --git a/services/frontend/components/user/profile-page-client.tsx b/services/frontend/components/user/profile-page-client.tsx new file mode 100644 index 00000000..ffcf8e86 --- /dev/null +++ b/services/frontend/components/user/profile-page-client.tsx @@ -0,0 +1,41 @@ +"use client"; + +import ErrorCard from "@/components/error/ErrorCard"; +import { UserProfile } from "@/components/user/profile"; +import { PageSkeleton } from "@/components/loading/page-skeleton"; +import { usePageSettings, useUserDetails } from "@/lib/swr/hooks/flows"; + +interface ProfilePageClientProps { + session?: string; +} + +export default function ProfilePageClient({ session }: ProfilePageClientProps) { + const { + settings, + isLoading: settingsLoading, + isError: settingsError, + } = usePageSettings(); + const { user, isLoading: userLoading, isError: userError } = useUserDetails(); + + // Check if any essential data is still loading or missing + const isLoading = settingsLoading || userLoading || !settings || !user; + + // Show loading state if essential data is still loading + if (isLoading) { + return ; + } + + // Show error state + const hasError = settingsError || userError; + + if (hasError) { + return ( + + ); + } + + return ; +} diff --git a/services/frontend/lib/swr/api/executions.ts b/services/frontend/lib/swr/api/executions.ts new file mode 100644 index 00000000..a459e392 --- /dev/null +++ b/services/frontend/lib/swr/api/executions.ts @@ -0,0 +1,25 @@ +import APIStartExecution from "@/lib/fetch/executions/start"; + +// Client-side API helpers for mutations +export async function startExecution( + flowId: string, +): Promise<{ success: boolean; error?: string }> { + try { + const result = await APIStartExecution(flowId); + + if (result.success) { + return { success: true }; + } else { + return { + success: false, + error: + "message" in result ? result.message : "Failed to start execution", + }; + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error occurred", + }; + } +} diff --git a/services/frontend/lib/swr/hooks/flows.ts b/services/frontend/lib/swr/hooks/flows.ts new file mode 100644 index 00000000..48456a23 --- /dev/null +++ b/services/frontend/lib/swr/hooks/flows.ts @@ -0,0 +1,250 @@ +import useSWR from "swr"; + +import { GetFlow } from "@/lib/fetch/flow/flow"; +import { GetFlowExecutions } from "@/lib/fetch/flow/executions"; +import GetFlows from "@/lib/fetch/flow/all"; +import GetProjects from "@/lib/fetch/project/all"; +import GetUserDetails from "@/lib/fetch/user/getDetails"; +import GetFolders from "@/lib/fetch/folder/all"; +import PageGetSettings from "@/lib/fetch/page/settings"; +import GetProjectRunners from "@/lib/fetch/project/runners"; +import GetProject from "@/lib/fetch/project/data"; +import GetRunners from "@/lib/fetch/runner/get"; +import GetRunningExecutions from "@/lib/fetch/executions/running"; +import GetUserStats from "@/lib/fetch/user/stats"; +import GetExecutionsWithAttention from "@/lib/fetch/executions/attention"; +import GetProjectAuditLogs from "@/lib/fetch/project/audit"; +import GetProjectApiKeys from "@/lib/fetch/project/tokens"; +import GetExecution from "@/lib/fetch/executions/execution"; + +// Hook for fetching a single flow +export function useFlow(flowId: string) { + const { data, error, mutate, isLoading } = useSWR( + flowId ? `flow-${flowId}` : null, + () => GetFlow(flowId), + ); + + return { + flow: data?.success ? data.data.flow : null, + isLoading, + isError: error || (data && !data.success), + refresh: mutate, + }; +} + +// Hook for fetching flow executions +export function useFlowExecutions(flowId: string) { + const { data, error, mutate, isLoading } = useSWR( + flowId ? `flow-executions-${flowId}` : null, + () => GetFlowExecutions(flowId, 50, 0), + ); + + return { + executions: data?.success ? data.data.executions : [], + total: data?.success ? data.data.total : 0, + isLoading, + isError: error || (data && !data.success), + refresh: mutate, + }; +} + +// Hook for fetching all flows +export function useFlows() { + const { data, error, mutate, isLoading } = useSWR("flows", () => GetFlows()); + + return { + flows: data?.success ? data.data.flows : [], + isLoading, + isError: error || (data && !data.success), + refresh: mutate, + }; +} + +// Hook for fetching all projects +export function useProjects() { + const { data, error, mutate, isLoading } = useSWR("projects", () => + GetProjects(), + ); + + return { + projects: data?.success ? data.data.projects : [], + pendingProjects: data?.success ? data.data.pending_projects : [], + isLoading, + isError: error || (data && !data.success), + refresh: mutate, + }; +} + +// Hook for fetching user details +export function useUserDetails() { + const { data, error, mutate, isLoading } = useSWR("user-details", () => + GetUserDetails(), + ); + + return { + user: data?.success ? data.data.user : null, + isLoading, + isError: error || (data && !data.success), + refresh: mutate, + }; +} + +// Hook for fetching folders +export function useFolders() { + const { data, error, mutate, isLoading } = useSWR("folders", () => + GetFolders(), + ); + + return { + folders: data?.success ? data.data.folders : [], + isLoading, + isError: error || (data && !data.success), + refresh: mutate, + }; +} + +// Hook for fetching page settings +export function usePageSettings() { + const { data, error, mutate, isLoading } = useSWR("page-settings", () => + PageGetSettings(), + ); + + return { + settings: data?.success ? data.data.settings : {}, + isLoading, + isError: error || (data && !data.success), + refresh: mutate, + }; +} + +// Hook for fetching project runners +export function useProjectRunners(projectId: string) { + const { data, error, mutate, isLoading } = useSWR( + projectId ? `project-runners-${projectId}` : null, + () => GetProjectRunners(projectId), + ); + + return { + runners: data?.success ? data.data.runners : [], + isLoading, + isError: error || (data && !data.success), + refresh: mutate, + }; +} + +// Hook for fetching a single project +export function useProject(projectId: string) { + const { data, error, mutate, isLoading } = useSWR( + projectId ? `project-${projectId}` : null, + () => GetProject(projectId), + ); + + return { + project: data?.success ? data.data.project : null, + isLoading, + isError: error || (data && !data.success), + refresh: mutate, + }; +} + +// Hook for fetching all runners +export function useRunners() { + const { data, error, mutate, isLoading } = useSWR("runners", () => + GetRunners(), + ); + + return { + runners: data?.success ? data.data.runners : [], + isLoading, + isError: error || (data && !data.success), + refresh: mutate, + }; +} + +// Hook for fetching running executions +export function useRunningExecutions() { + const { data, error, mutate, isLoading } = useSWR("running-executions", () => + GetRunningExecutions(), + ); + + return { + runningExecutions: data?.success ? data.data : [], + isLoading, + isError: error || (data && !data.success), + refresh: mutate, + }; +} + +// Hook for fetching user stats +export function useUserStats() { + const { data, error, mutate, isLoading } = useSWR("user-stats", () => + GetUserStats(), + ); + + return { + stats: data?.success ? data.data.stats : null, + isLoading, + isError: error || (data && !data.success), + refresh: mutate, + }; +} + +// Hook for fetching executions with attention +export function useExecutionsWithAttention() { + const { data, error, mutate, isLoading } = useSWR( + "executions-with-attention", + () => GetExecutionsWithAttention(), + ); + + return { + executionsWithAttention: data?.success ? data.data.executions : [], + isLoading, + isError: error || (data && !data.success), + refresh: mutate, + }; +} + +// Hook for fetching project audit logs +export function useProjectAuditLogs(projectId: string) { + const { data, error, mutate, isLoading } = useSWR( + projectId ? `project-audit-${projectId}` : null, + () => GetProjectAuditLogs(projectId), + ); + + return { + audit: data?.success ? data.data.audit : [], + isLoading, + isError: error || (data && !data.success), + refresh: mutate, + }; +} + +// Hook for fetching project API keys +export function useProjectApiKeys(projectId: string) { + const { data, error, mutate, isLoading } = useSWR( + projectId ? `project-tokens-${projectId}` : null, + () => GetProjectApiKeys(projectId), + ); + + return { + tokens: data?.success ? data.data.tokens : [], + isLoading, + isError: error || (data && !data.success), + refresh: mutate, + }; +} + +// Hook for fetching a single execution +export function useExecution(executionId: string) { + const { data, error, mutate, isLoading } = useSWR( + executionId ? `execution-${executionId}` : null, + () => GetExecution(executionId), + ); + + return { + execution: data?.success ? data.data.execution : null, + isLoading, + isError: error || (data && !data.success), + refresh: mutate, + }; +} diff --git a/services/frontend/lib/swr/hooks/useRefreshCache.ts b/services/frontend/lib/swr/hooks/useRefreshCache.ts new file mode 100644 index 00000000..356995fa --- /dev/null +++ b/services/frontend/lib/swr/hooks/useRefreshCache.ts @@ -0,0 +1,80 @@ +import { mutate } from "swr"; + +/** + * Custom hook that provides SWR cache refresh functions for different data types + * Use this in modals instead of router.refresh() to update SWR cache after mutations + * + * This approach uses direct cache key mutations instead of importing all hooks + * to avoid potential circular dependencies and bundle size issues. + */ +export function useRefreshCache() { + return { + // Direct cache key refreshes + refreshFlows: () => mutate("flows"), + refreshProjects: () => mutate("projects"), + refreshFolders: () => mutate("folders"), + refreshRunners: () => mutate("runners"), + refreshUser: () => mutate("user-details"), + refreshUserStats: () => mutate("user-stats"), + refreshExecutionsWithAttention: () => mutate("executions-with-attention"), + refreshRunningExecutions: () => mutate("running-executions"), + refreshPageSettings: () => mutate("page-settings"), + + // Specific entity refreshes + refreshFlow: (flowId: string) => mutate(`flow-${flowId}`), + refreshFlowExecutions: (flowId: string) => + mutate(`flow-executions-${flowId}`), + refreshProject: (projectId: string) => mutate(`project-${projectId}`), + refreshProjectRunners: (projectId: string) => + mutate(`project-runners-${projectId}`), + refreshProjectAudit: (projectId: string) => + mutate(`project-audit-${projectId}`), + refreshProjectTokens: (projectId: string) => + mutate(`project-tokens-${projectId}`), + refreshExecution: (executionId: string) => + mutate(`execution-${executionId}`), + refreshFolder: (folderId: string) => mutate(`folder-${folderId}`), + refreshFolderExecutions: (folderId: string) => + mutate(`folder-executions-${folderId}`), + + // Convenience methods for common combinations + refreshAll: () => { + mutate("flows"); + mutate("projects"); + mutate("folders"); + mutate("runners"); + mutate("user-details"); + mutate("user-stats"); + mutate("executions-with-attention"); + mutate("running-executions"); + mutate("page-settings"); + }, + + refreshProjectData: () => { + mutate("projects"); + mutate("flows"); + mutate("folders"); + mutate("runners"); + }, + + refreshFlowData: (flowId?: string) => { + mutate("flows"); + mutate("running-executions"); + mutate("executions-with-attention"); + if (flowId) { + mutate(`flow-${flowId}`); + mutate(`flow-executions-${flowId}`); + } + }, + + refreshAllFlowData: (flowId?: string) => { + mutate("flows"); + mutate("running-executions"); + mutate("executions-with-attention"); + if (flowId) { + mutate(`flow-${flowId}`); + mutate(`flow-executions-${flowId}`); + } + }, + }; +} diff --git a/services/frontend/lib/swr/provider.tsx b/services/frontend/lib/swr/provider.tsx new file mode 100644 index 00000000..8d710cbc --- /dev/null +++ b/services/frontend/lib/swr/provider.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { SWRConfig } from "swr"; +import { ReactNode } from "react"; + +interface SWRProviderProps { + children: ReactNode; +} + +export default function SWRProvider({ children }: SWRProviderProps) { + return ( + { + // Handle SWR errors silently in production + // You could send to an error reporting service here + }, + }} + > + {children} + + ); +} diff --git a/services/frontend/package.json b/services/frontend/package.json index 7d1a67cb..f5bd347e 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -84,6 +84,7 @@ "react-timeago": "^7.2.0", "recharts": "2.15.1", "scroll-into-view-if-needed": "^3.1.0", + "swr": "^2.3.4", "tailwind-merge": "^2.6.0", "tailwind-variants": "1.0.0", "tailwindcss": "3.4.17", diff --git a/services/frontend/pnpm-lock.yaml b/services/frontend/pnpm-lock.yaml index 822062e6..1c7a014a 100644 --- a/services/frontend/pnpm-lock.yaml +++ b/services/frontend/pnpm-lock.yaml @@ -230,6 +230,9 @@ importers: scroll-into-view-if-needed: specifier: ^3.1.0 version: 3.1.0 + swr: + specifier: ^2.3.4 + version: 2.3.4(react@19.0.0) tailwind-merge: specifier: ^2.6.0 version: 2.6.0 @@ -6729,6 +6732,11 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + swr@2.3.4: + resolution: {integrity: sha512-bYd2lrhc+VarcpkgWclcUi92wYCpOgMws9Sd1hG1ntAu0NEy+14CbotuFjshBU2kt9rYj9TSmDcybpxpeTU1fg==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + synckit@0.10.3: resolution: {integrity: sha512-R1urvuyiTaWfeCggqEvpDJwAlDVdsT9NM+IP//Tk2x7qHCkSvBk/fwFgw/TLAHzZlrAnnazMcRw0ZD8HlYFTEQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -15812,6 +15820,12 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + swr@2.3.4(react@19.0.0): + dependencies: + dequal: 2.0.3 + react: 19.0.0 + use-sync-external-store: 1.5.0(react@19.0.0) + synckit@0.10.3: dependencies: '@pkgr/core': 0.2.0 From 6f8c14420c2893bd40c5785f19bdbf40bc970fdb Mon Sep 17 00:00:00 2001 From: Justin Neubert Date: Tue, 19 Aug 2025 14:31:37 +0200 Subject: [PATCH 12/20] Refactor execution and flow components to utilize SWR for data fetching and cache management - Replaced direct API calls with SWR hooks in execution and flow components for better data management. - Implemented auto-refresh for running executions using SWR's refreshInterval feature. - Removed the Reloader component and integrated refresh functionality directly into relevant components. - Enhanced cache refreshing logic to ensure all related data is updated after actions like add, edit, and delete. - Updated the useRefreshCache hook to include methods for refreshing execution and flow data. - Adjusted pagination and filtering logic to work seamlessly with the new SWR setup. --- .../frontend/components/dashboard/home.tsx | 14 +- .../execution/adminExecutionActions.tsx | 6 +- .../executions/execution/adminStepActions.tsx | 6 +- .../executions/execution/execution.tsx | 67 +++++++--- .../execution/executionStepsAccordion.tsx | 25 ++-- .../execution/executionStepsTable.tsx | 9 +- .../components/executions/executions.tsx | 122 +++++++----------- .../executions/executionsCompact.tsx | 2 +- .../components/flows/flow/heading.tsx | 26 +--- .../components/modals/actions/add.tsx | 9 +- .../components/modals/actions/copy.tsx | 9 +- .../components/modals/actions/delete.tsx | 8 +- .../components/modals/actions/edit.tsx | 8 +- .../components/modals/actions/editDetails.tsx | 6 +- .../components/modals/actions/upgrade.tsx | 8 +- .../components/modals/executions/delete.tsx | 8 +- .../components/modals/executions/schedule.tsx | 6 +- .../modals/failurePipelines/create.tsx | 6 +- .../modals/failurePipelines/delete.tsx | 6 +- .../modals/failurePipelines/edit.tsx | 6 +- .../frontend/components/projects/project.tsx | 4 - .../frontend/components/reloader/Reloader.tsx | 39 ------ .../frontend/components/runners/heading.tsx | 5 - services/frontend/lib/swr/hooks/flows.ts | 68 ++++++++++ .../frontend/lib/swr/hooks/useRefreshCache.ts | 46 +++++++ services/frontend/lib/swr/provider.tsx | 2 +- 26 files changed, 286 insertions(+), 235 deletions(-) delete mode 100644 services/frontend/components/reloader/Reloader.tsx diff --git a/services/frontend/components/dashboard/home.tsx b/services/frontend/components/dashboard/home.tsx index 21271839..3c947f7d 100644 --- a/services/frontend/components/dashboard/home.tsx +++ b/services/frontend/components/dashboard/home.tsx @@ -18,7 +18,6 @@ import ReactTimeago from "react-timeago"; import WelcomeModal from "@/components/modals/user/welcome"; import Stats from "@/components/dashboard/stats"; -import Reloader from "../reloader/Reloader"; import Executions from "../executions/executions"; export default function DashboardHome({ @@ -66,14 +65,11 @@ export default function DashboardHome({ return (
-
-
-

Hello, {user.username} 👋

-

- Here's the current status for today. -

-
- +
+

Hello, {user.username} 👋

+

+ Here's the current status for today. +

diff --git a/services/frontend/components/executions/execution/adminExecutionActions.tsx b/services/frontend/components/executions/execution/adminExecutionActions.tsx index 8ac20d0f..fe406aeb 100644 --- a/services/frontend/components/executions/execution/adminExecutionActions.tsx +++ b/services/frontend/components/executions/execution/adminExecutionActions.tsx @@ -8,16 +8,16 @@ import { DropdownSection, DropdownTrigger, } from "@heroui/react"; -import { useRouter } from "next/navigation"; import UpdateExecution from "@/lib/fetch/executions/PUT/update"; +import { useRefreshCache } from "@/lib/swr/hooks/useRefreshCache"; export default function AdminExecutionActions({ execution, }: { execution: any; }) { - const router = useRouter(); + const { refreshExecution } = useRefreshCache(); async function changeExecutionStatus(status: string) { const newExecution = { ...execution }; @@ -106,7 +106,7 @@ export default function AdminExecutionActions({ color: "success", variant: "flat", }); - router.refresh(); + refreshExecution(execution.id); } else { addToast({ title: "Execution", diff --git a/services/frontend/components/executions/execution/adminStepActions.tsx b/services/frontend/components/executions/execution/adminStepActions.tsx index 1a463401..c60c0638 100644 --- a/services/frontend/components/executions/execution/adminStepActions.tsx +++ b/services/frontend/components/executions/execution/adminStepActions.tsx @@ -8,9 +8,9 @@ import { DropdownSection, DropdownTrigger, } from "@heroui/react"; -import { useRouter } from "next/navigation"; import UpdateExecutionStep from "@/lib/fetch/executions/PUT/updateStep"; +import { useRefreshCache } from "@/lib/swr/hooks/useRefreshCache"; export default function AdminStepActions({ execution, @@ -19,7 +19,7 @@ export default function AdminStepActions({ execution: any; step: any; }) { - const router = useRouter(); + const { refreshExecutionSteps } = useRefreshCache(); async function changeStepStatus(status: string) { const newStep = { ...step }; @@ -317,7 +317,7 @@ export default function AdminStepActions({ color: "success", variant: "flat", }); - router.refresh(); + refreshExecutionSteps(execution.id); } else { addToast({ title: "Execution", diff --git a/services/frontend/components/executions/execution/execution.tsx b/services/frontend/components/executions/execution/execution.tsx index ce26b152..0263d446 100644 --- a/services/frontend/components/executions/execution/execution.tsx +++ b/services/frontend/components/executions/execution/execution.tsx @@ -3,12 +3,13 @@ import { Icon } from "@iconify/react"; import { addToast, Button, ButtonGroup, Divider, Spacer } from "@heroui/react"; import { useRouter } from "next/navigation"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; -import Reloader from "@/components/reloader/Reloader"; -import GetExecutionSteps from "@/lib/fetch/executions/steps"; +import { useExecutionSteps } from "@/lib/swr/hooks/flows"; import APICancelExecution from "@/lib/fetch/executions/cancel"; import { useExecutionStepStyleStore } from "@/lib/functions/userExecutionStepStyle"; +import RefreshButton from "@/components/ui/refresh-button"; +import { useRefreshCache } from "@/lib/swr/hooks/useRefreshCache"; import AdminExecutionActions from "./adminExecutionActions"; import ExecutionDetails from "./details"; @@ -19,24 +20,38 @@ export function Execution({ flow, execution, runners, userDetails }: any) { const router = useRouter(); const { displayStyle, setDisplayStyle } = useExecutionStepStyleStore(); - const [steps, setSteps] = useState([] as any); - useEffect(() => { - GetExecutionSteps(execution.id).then((steps) => { - if (steps.success) { - setSteps(steps.data.steps); - } else { - if ("error" in steps) { - addToast({ - title: "Execution", - description: steps.error, - color: "danger", - variant: "flat", - }); - } - } - }); - }, [execution]); + // Check if execution is running to enable auto-refresh + const isRunning = + execution.status === "running" || + execution.status === "pending" || + execution.status === "paused" || + execution.status === "scheduled" || + execution.status === "interactionWaiting"; + + // Use SWR for auto-refreshing execution steps data + const { steps, isError } = useExecutionSteps(execution.id, isRunning); + const { refreshExecution, refreshExecutionSteps } = useRefreshCache(); + const [executionLoading, setExecutionLoading] = useState(false); + + // Handle SWR errors + React.useEffect(() => { + if (isError) { + addToast({ + title: "Error fetching execution steps", + description: "Failed to load execution steps. Please try refreshing.", + color: "danger", + variant: "flat", + }); + } + }, [isError]); + + const handleRefresh = async () => { + setExecutionLoading(true); + await refreshExecution(execution.id); + await refreshExecutionSteps(execution.id); + setExecutionLoading(false); + }; return ( <> @@ -114,7 +129,17 @@ export function Execution({ flow, execution, runners, userDetails }: any) { execution.status === "interactionWaiting") && (
- + {isRunning && ( +
+ + Auto-refresh 2s +
+ )} +
)}
diff --git a/services/frontend/components/executions/execution/executionStepsAccordion.tsx b/services/frontend/components/executions/execution/executionStepsAccordion.tsx index 2874bf58..7fb65f2e 100644 --- a/services/frontend/components/executions/execution/executionStepsAccordion.tsx +++ b/services/frontend/components/executions/execution/executionStepsAccordion.tsx @@ -11,12 +11,12 @@ import { Progress, Snippet, } from "@heroui/react"; -import { useRouter } from "next/navigation"; import React, { useEffect, useState, useRef } from "react"; import { isMobile, isTablet } from "react-device-detect"; import InteractExecutionStep from "@/lib/fetch/executions/PUT/step_interact"; import { executionStatusWrapper } from "@/lib/functions/executionStyles"; +import { useRefreshCache } from "@/lib/swr/hooks/useRefreshCache"; import AdminStepActions from "./adminStepActions"; @@ -27,7 +27,7 @@ export function ExecutionStepsAccordion({ runners, userDetails, }: any) { - const router = useRouter(); + const { refreshExecution, refreshExecutionSteps } = useRefreshCache(); const [parSteps, setParSteps] = useState([] as any); const [selectedKeys, setSelectedKeys] = React.useState(new Set(["1"])); @@ -98,19 +98,19 @@ export function ExecutionStepsAccordion({ (step: any) => step.status !== "pending", ); const activeStep = nonPendingSteps[nonPendingSteps.length - 1]; - + if (activeStep) { const stepElement = stepItemRef.current[activeStep.id]; if (stepElement) { // Set flag to prevent scroll listener from interfering isAutoScrollingRef.current = true; - + stepElement.scrollIntoView({ behavior: "smooth", block: "center", }); - + // Clear the flag after scrolling is complete setTimeout(() => { isAutoScrollingRef.current = false; @@ -248,22 +248,22 @@ export function ExecutionStepsAccordion({ (step: any) => step.status !== "pending", ); const activeStep = nonPendingSteps[nonPendingSteps.length - 1]; - + if (activeStep) { const stepElement = stepItemRef.current[activeStep.id]; if (stepElement) { // Set flag to prevent scroll listener from interfering isAutoScrollingRef.current = true; - + stepElement.scrollIntoView({ behavior: "smooth", block: "center", }); - + setStepAutoScrollEnabled(true); setUserSelected(false); - + // Clear the flag after scrolling is complete setTimeout(() => { isAutoScrollingRef.current = false; @@ -338,7 +338,10 @@ export function ExecutionStepsAccordion({ color: "success", variant: "flat", }); - router.refresh(); + // wait 1 second + await new Promise((resolve) => setTimeout(resolve, 1000)); + await refreshExecutionSteps(execution.id); + await refreshExecution(execution.id); } } @@ -623,7 +626,7 @@ export function ExecutionStepsAccordion({
- + {/* Floating auto-scroll button - fixed position */} {!stepAutoScrollEnabled && ( - - {/* Mobile */} @@ -132,12 +118,6 @@ export default function FlowHeading({ onPress={handleExecuteFlow} /> - -
- -
diff --git a/services/frontend/components/reloader/Reloader.tsx b/services/frontend/components/reloader/Reloader.tsx deleted file mode 100644 index 90461eda..00000000 --- a/services/frontend/components/reloader/Reloader.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; - -import { CircularProgress, Progress } from "@heroui/react"; -import { useRouter } from "next/navigation"; -import React from "react"; - -export default function Reloader({ - circle = false, - refresh = 50, -}: { - circle?: boolean; - refresh?: number; -}) { - const [value, setValue] = React.useState(0); - const router = useRouter(); - - React.useEffect(() => { - const interval = setInterval(() => { - setValue((v) => (v >= 100 ? 0 : v + refresh)); - if (value === 100) { - clearInterval(interval); - router.refresh(); - } - }, 1000); - - return () => clearInterval(interval); - }, [value]); - - return circle ? ( - - ) : ( - - ); -} diff --git a/services/frontend/components/runners/heading.tsx b/services/frontend/components/runners/heading.tsx index 89aacdef..0b7dd649 100644 --- a/services/frontend/components/runners/heading.tsx +++ b/services/frontend/components/runners/heading.tsx @@ -1,15 +1,10 @@ "use client"; -import Reloader from "../reloader/Reloader"; - export default function RunnersHeading() { return (

Runners

-
- -
); diff --git a/services/frontend/lib/swr/hooks/flows.ts b/services/frontend/lib/swr/hooks/flows.ts index 48456a23..45bc11a0 100644 --- a/services/frontend/lib/swr/hooks/flows.ts +++ b/services/frontend/lib/swr/hooks/flows.ts @@ -16,6 +16,8 @@ import GetExecutionsWithAttention from "@/lib/fetch/executions/attention"; import GetProjectAuditLogs from "@/lib/fetch/project/audit"; import GetProjectApiKeys from "@/lib/fetch/project/tokens"; import GetExecution from "@/lib/fetch/executions/execution"; +import GetExecutions from "@/lib/fetch/executions/all"; +import GetExecutionSteps from "@/lib/fetch/executions/steps"; // Hook for fetching a single flow export function useFlow(flowId: string) { @@ -48,6 +50,49 @@ export function useFlowExecutions(flowId: string) { }; } +// Hook for fetching paginated flow executions with filters +export function useFlowExecutionsPaginated( + flowId: string, + limit: number = 10, + offset: number = 0, + status: string | null = null, +) { + const { data, error, mutate, isLoading } = useSWR( + flowId + ? `flow-executions-paginated-${flowId}-${limit}-${offset}-${status || "all"}` + : null, + () => GetFlowExecutions(flowId, limit, offset, status), + ); + + return { + executions: data?.success ? data.data.executions : [], + total: data?.success ? data.data.total : 0, + isLoading, + isError: error || (data && !data.success), + refresh: mutate, + }; +} + +// Hook for fetching all executions with pagination and filters +export function useExecutions( + limit: number = 10, + offset: number = 0, + status: string | null = null, +) { + const { data, error, mutate, isLoading } = useSWR( + limit > 0 ? `executions-${limit}-${offset}-${status || "all"}` : null, + () => GetExecutions(limit, offset, status), + ); + + return { + executions: data?.success ? data.data.executions : [], + total: data?.success ? data.data.total : 0, + isLoading, + isError: error || (data && !data.success), + refresh: mutate, + }; +} + // Hook for fetching all flows export function useFlows() { const { data, error, mutate, isLoading } = useSWR("flows", () => GetFlows()); @@ -248,3 +293,26 @@ export function useExecution(executionId: string) { refresh: mutate, }; } + +// Hook for fetching execution steps with auto-refresh for running executions +export function useExecutionSteps( + executionId: string, + isRunning: boolean = false, +) { + const { data, error, mutate, isLoading } = useSWR( + executionId ? `execution-steps-${executionId}` : null, + () => GetExecutionSteps(executionId), + { + refreshInterval: isRunning ? 2000 : 0, // Refresh every 2 seconds if running + refreshWhenHidden: false, + refreshWhenOffline: false, + }, + ); + + return { + steps: data?.success ? data.data.steps : [], + isLoading, + isError: error || (data && !data.success), + refresh: mutate, + }; +} diff --git a/services/frontend/lib/swr/hooks/useRefreshCache.ts b/services/frontend/lib/swr/hooks/useRefreshCache.ts index 356995fa..0bb1238f 100644 --- a/services/frontend/lib/swr/hooks/useRefreshCache.ts +++ b/services/frontend/lib/swr/hooks/useRefreshCache.ts @@ -24,6 +24,20 @@ export function useRefreshCache() { refreshFlow: (flowId: string) => mutate(`flow-${flowId}`), refreshFlowExecutions: (flowId: string) => mutate(`flow-executions-${flowId}`), + refreshFlowExecutionsPaginated: ( + flowId: string, + limit: number, + offset: number, + status: string | null = null, + ) => + mutate( + `flow-executions-paginated-${flowId}-${limit}-${offset}-${status || "all"}`, + ), + refreshExecutions: ( + limit: number, + offset: number, + status: string | null = null, + ) => mutate(`executions-${limit}-${offset}-${status || "all"}`), refreshProject: (projectId: string) => mutate(`project-${projectId}`), refreshProjectRunners: (projectId: string) => mutate(`project-runners-${projectId}`), @@ -33,10 +47,42 @@ export function useRefreshCache() { mutate(`project-tokens-${projectId}`), refreshExecution: (executionId: string) => mutate(`execution-${executionId}`), + refreshExecutionSteps: (executionId: string) => + mutate(`execution-steps-${executionId}`), refreshFolder: (folderId: string) => mutate(`folder-${folderId}`), refreshFolderExecutions: (folderId: string) => mutate(`folder-executions-${folderId}`), + // Helper to refresh all execution-related caches (useful after deletion) + refreshAllExecutionCaches: (flowId?: string) => { + // Refresh general execution caches + mutate("executions-with-attention"); + mutate("running-executions"); + + // Refresh all paginated execution caches with common pagination values + const limits = [4, 6, 10]; // Common limits used in the app + const offsets = [0, 10, 20, 30]; // Common offset values + const statuses = [null, "all"]; // Common status filters + + limits.forEach((limit) => { + offsets.forEach((offset) => { + statuses.forEach((status) => { + mutate(`executions-${limit}-${offset}-${status || "all"}`); + if (flowId) { + mutate( + `flow-executions-paginated-${flowId}-${limit}-${offset}-${status || "all"}`, + ); + } + }); + }); + }); + + // Refresh specific flow executions if flowId provided + if (flowId) { + mutate(`flow-executions-${flowId}`); + } + }, + // Convenience methods for common combinations refreshAll: () => { mutate("flows"); diff --git a/services/frontend/lib/swr/provider.tsx b/services/frontend/lib/swr/provider.tsx index 8d710cbc..e338ec5b 100644 --- a/services/frontend/lib/swr/provider.tsx +++ b/services/frontend/lib/swr/provider.tsx @@ -13,7 +13,7 @@ export default function SWRProvider({ children }: SWRProviderProps) { value={{ revalidateOnFocus: true, revalidateOnReconnect: true, - refreshInterval: 30000, // Refresh every 30 seconds + refreshInterval: 10000, // Refresh every 10 seconds errorRetryCount: 3, errorRetryInterval: 5000, dedupingInterval: 2000, From cb0995fba2d63b3502aaafe0ce445e63696cf1d5 Mon Sep 17 00:00:00 2001 From: Justin Neubert Date: Tue, 19 Aug 2025 16:28:05 +0200 Subject: [PATCH 13/20] refactor: replace useRouter with useRefreshCache for improved cache management in project modals --- .../components/modals/projects/changeTokenStatus.tsx | 8 ++++---- .../components/modals/projects/createToken.tsx | 12 +++++++++--- .../components/modals/projects/deleteToken.tsx | 6 +++--- .../components/modals/projects/editMember.tsx | 6 +++--- .../frontend/components/modals/projects/leave.tsx | 6 +++--- .../frontend/components/modals/projects/members.tsx | 6 +++--- .../components/modals/projects/removeMember.tsx | 6 +++--- .../modals/projects/rotateAutoJoinToken.tsx | 6 +++--- .../components/modals/projects/transferOwnership.tsx | 6 +++--- .../components/modals/runner/changeStatus.tsx | 8 ++++---- .../frontend/components/modals/runner/create.tsx | 6 +++--- .../frontend/components/modals/runner/delete.tsx | 8 ++++---- services/frontend/components/modals/runner/edit.tsx | 6 +++--- .../components/modals/tokens/deleteRunnerToken.tsx | 6 +++--- services/frontend/components/modals/tokens/edit.tsx | 6 +++--- services/frontend/components/modals/user/welcome.tsx | 4 +--- 16 files changed, 55 insertions(+), 51 deletions(-) diff --git a/services/frontend/components/modals/projects/changeTokenStatus.tsx b/services/frontend/components/modals/projects/changeTokenStatus.tsx index bfeb3c2d..59bbc609 100644 --- a/services/frontend/components/modals/projects/changeTokenStatus.tsx +++ b/services/frontend/components/modals/projects/changeTokenStatus.tsx @@ -11,12 +11,12 @@ import { ModalHeader, Snippet, } from "@heroui/react"; -import { useRouter } from "next/navigation"; import React from "react"; import { Icon } from "@iconify/react"; import ErrorCard from "@/components/error/ErrorCard"; import ChangeProjectTokenStatus from "@/lib/fetch/project/PUT/ChangeProjectTokenStatus"; +import { useRefreshCache } from "@/lib/swr/hooks/useRefreshCache"; export default function ChangeProjectTokenStatusModal({ disclosure, @@ -29,7 +29,7 @@ export default function ChangeProjectTokenStatusModal({ token: any; disabled: any; }) { - const router = useRouter(); + const { refreshProjectTokens } = useRefreshCache(); const { isOpen, onOpenChange } = disclosure; @@ -70,7 +70,7 @@ export default function ChangeProjectTokenStatusModal({ setErrorText(""); setErrorMessage(""); onOpenChange(); - router.refresh(); + refreshProjectTokens(projectID); addToast({ title: "Project", description: "Token status updated successfully", @@ -82,7 +82,7 @@ export default function ChangeProjectTokenStatusModal({ setError(true); setErrorText(res.error); setErrorMessage(res.message); - router.refresh(); + refreshProjectTokens(projectID); addToast({ title: "Project", description: "Failed to update token status", diff --git a/services/frontend/components/modals/projects/createToken.tsx b/services/frontend/components/modals/projects/createToken.tsx index b94fc4e8..b0b66b5c 100644 --- a/services/frontend/components/modals/projects/createToken.tsx +++ b/services/frontend/components/modals/projects/createToken.tsx @@ -12,12 +12,12 @@ import { ModalContent, ModalHeader, } from "@heroui/react"; -import { useRouter } from "next/navigation"; import React, { useState } from "react"; import { Icon } from "@iconify/react"; import ErrorCard from "@/components/error/ErrorCard"; import CreateProjectToken from "@/lib/fetch/project/POST/CreateProjectToken"; +import { useRefreshCache } from "@/lib/swr/hooks/useRefreshCache"; export default function CreateProjectTokenModal({ disclosure, @@ -26,7 +26,7 @@ export default function CreateProjectTokenModal({ disclosure: UseDisclosureReturn; projectID: any; }) { - const router = useRouter(); + const { refreshProjectTokens } = useRefreshCache(); const { isOpen, onOpenChange } = disclosure; const [errors] = useState({}); @@ -65,8 +65,14 @@ export default function CreateProjectTokenModal({ } if (res.success) { - router.refresh(); + refreshProjectTokens(projectID); onOpenChange(); + addToast({ + title: "Project", + description: "Token created successfully", + color: "success", + variant: "flat", + }); } else { setApiError(true); setApiErrorText(res.error); diff --git a/services/frontend/components/modals/projects/deleteToken.tsx b/services/frontend/components/modals/projects/deleteToken.tsx index b1dc3c8d..016df7bf 100644 --- a/services/frontend/components/modals/projects/deleteToken.tsx +++ b/services/frontend/components/modals/projects/deleteToken.tsx @@ -12,12 +12,12 @@ import { ModalHeader, Snippet, } from "@heroui/react"; -import { useRouter } from "next/navigation"; import React from "react"; import { Icon } from "@iconify/react"; import ErrorCard from "@/components/error/ErrorCard"; import DeleteProjectToken from "@/lib/fetch/project/DELETE/DeleteProjectToken"; +import { useRefreshCache } from "@/lib/swr/hooks/useRefreshCache"; export default function DeleteProjectTokenModal({ disclosure, @@ -28,7 +28,7 @@ export default function DeleteProjectTokenModal({ projectID: any; token: any; }) { - const router = useRouter(); + const { refreshProjectTokens } = useRefreshCache(); const { isOpen, onOpenChange } = disclosure; const [isLoading, setIsLoading] = React.useState(false); @@ -61,7 +61,7 @@ export default function DeleteProjectTokenModal({ setError(false); setErrorText(""); setErrorMessage(""); - router.refresh(); + refreshProjectTokens(projectID); onOpenChange(); addToast({ title: "Token", diff --git a/services/frontend/components/modals/projects/editMember.tsx b/services/frontend/components/modals/projects/editMember.tsx index 15cd9c4e..77a85f01 100644 --- a/services/frontend/components/modals/projects/editMember.tsx +++ b/services/frontend/components/modals/projects/editMember.tsx @@ -13,12 +13,12 @@ import { Select, SelectItem, } from "@heroui/react"; -import { useRouter } from "next/navigation"; import React, { useEffect, useState } from "react"; import { Icon } from "@iconify/react"; import EditProjectMember from "@/lib/fetch/project/PUT/editProjectMember"; import ErrorCard from "@/components/error/ErrorCard"; +import { useRefreshCache } from "@/lib/swr/hooks/useRefreshCache"; export default function EditProjectMemberModal({ disclosure, @@ -29,7 +29,7 @@ export default function EditProjectMemberModal({ projectID: string; user: any; }) { - const router = useRouter(); + const { refreshProject } = useRefreshCache(); const { isOpen, onOpenChange } = disclosure; const [isLoginLoading, setIsLoginLoading] = useState(false); const [role, setRole] = React.useState(user.role); @@ -82,7 +82,7 @@ export default function EditProjectMemberModal({ color: "success", variant: "flat", }); - router.refresh(); + refreshProject(projectID); } else { setError(true); setErrorText(response.error); diff --git a/services/frontend/components/modals/projects/leave.tsx b/services/frontend/components/modals/projects/leave.tsx index daf2f555..13a18130 100644 --- a/services/frontend/components/modals/projects/leave.tsx +++ b/services/frontend/components/modals/projects/leave.tsx @@ -11,12 +11,12 @@ import { ModalFooter, ModalHeader, } from "@heroui/react"; -import { useRouter } from "next/navigation"; import React, { useState } from "react"; import { Icon } from "@iconify/react"; import LeaveProject from "@/lib/fetch/project/DELETE/leave"; import ErrorCard from "@/components/error/ErrorCard"; +import { useRefreshCache } from "@/lib/swr/hooks/useRefreshCache"; export default function LeaveProjectModal({ disclosure, @@ -25,7 +25,7 @@ export default function LeaveProjectModal({ disclosure: UseDisclosureReturn; projectID: string; }) { - const router = useRouter(); + const { refreshProject } = useRefreshCache(); const { isOpen, onOpenChange } = disclosure; const [isLeaveLoading, setIsLeaveLoading] = useState(false); @@ -65,7 +65,7 @@ export default function LeaveProjectModal({ color: "success", variant: "flat", }); - router.push("/projects"); + refreshProject(projectID); } else { setIsLeaveLoading(false); setError(true); diff --git a/services/frontend/components/modals/projects/members.tsx b/services/frontend/components/modals/projects/members.tsx index 609e1cff..738013e1 100644 --- a/services/frontend/components/modals/projects/members.tsx +++ b/services/frontend/components/modals/projects/members.tsx @@ -24,11 +24,11 @@ import { ModalContent, Spacer, } from "@heroui/react"; -import { useRouter } from "next/navigation"; import React from "react"; import AddProjectMember from "@/lib/fetch/project/POST/AddProjectMember"; import ErrorCard from "@/components/error/ErrorCard"; +import { useRefreshCache } from "@/lib/swr/hooks/useRefreshCache"; import UserCell from "./user-cell"; @@ -39,7 +39,7 @@ export default function AddProjectMemberModal({ disclosure: UseDisclosureReturn; project: any; }) { - const router = useRouter(); + const { refreshProject } = useRefreshCache(); const { isOpen, onOpenChange } = disclosure; const [email, setEmail] = React.useState(""); @@ -120,7 +120,7 @@ export default function AddProjectMemberModal({ setErrorText(""); setErrorMessage(""); onOpenChange(); - router.refresh(); + refreshProject(project.id); addToast({ title: "Project", description: "Member invited successfully", diff --git a/services/frontend/components/modals/projects/removeMember.tsx b/services/frontend/components/modals/projects/removeMember.tsx index 6de9a6d0..9d096b1f 100644 --- a/services/frontend/components/modals/projects/removeMember.tsx +++ b/services/frontend/components/modals/projects/removeMember.tsx @@ -13,12 +13,12 @@ import { ModalHeader, User, } from "@heroui/react"; -import { useRouter } from "next/navigation"; import React, { useState } from "react"; import { Icon } from "@iconify/react"; import RemoveProjectMember from "@/lib/fetch/project/DELETE/removeProjectMember"; import ErrorCard from "@/components/error/ErrorCard"; +import { useRefreshCache } from "@/lib/swr/hooks/useRefreshCache"; export default function DeleteProjectMemberModal({ disclosure, @@ -29,7 +29,7 @@ export default function DeleteProjectMemberModal({ projectID: string; user: any; }) { - const router = useRouter(); + const { refreshProject } = useRefreshCache(); const { isOpen, onOpenChange } = disclosure; const [isDeleteLoading, setIsDeleteLoading] = useState(false); @@ -75,7 +75,7 @@ export default function DeleteProjectMemberModal({ color: "success", variant: "flat", }); - router.refresh(); + refreshProject(projectID); } else { setError(true); setErrorText(res.error); diff --git a/services/frontend/components/modals/projects/rotateAutoJoinToken.tsx b/services/frontend/components/modals/projects/rotateAutoJoinToken.tsx index 4dcd3eec..1d270827 100644 --- a/services/frontend/components/modals/projects/rotateAutoJoinToken.tsx +++ b/services/frontend/components/modals/projects/rotateAutoJoinToken.tsx @@ -11,12 +11,12 @@ import { ModalFooter, ModalHeader, } from "@heroui/react"; -import { useRouter } from "next/navigation"; import React from "react"; import { Icon } from "@iconify/react"; import ErrorCard from "@/components/error/ErrorCard"; import RotateAutoJoinToken from "@/lib/fetch/project/PUT/RotateAutoJoinToken"; +import { useRefreshCache } from "@/lib/swr/hooks/useRefreshCache"; export default function RotateAutoJoinTokenModal({ disclosure, @@ -25,7 +25,7 @@ export default function RotateAutoJoinTokenModal({ disclosure: UseDisclosureReturn; projectID: any; }) { - const router = useRouter(); + const { refreshProject } = useRefreshCache(); const { isOpen, onOpenChange } = disclosure; const [isLoading, setIsLoading] = React.useState(false); @@ -58,7 +58,7 @@ export default function RotateAutoJoinTokenModal({ setError(false); setErrorText(""); setErrorMessage(""); - router.refresh(); + refreshProject(projectID); onOpenChange(); addToast({ title: "Token", diff --git a/services/frontend/components/modals/projects/transferOwnership.tsx b/services/frontend/components/modals/projects/transferOwnership.tsx index b0398d00..c3e58fbd 100644 --- a/services/frontend/components/modals/projects/transferOwnership.tsx +++ b/services/frontend/components/modals/projects/transferOwnership.tsx @@ -21,12 +21,12 @@ import { Spacer, User, } from "@heroui/react"; -import { useRouter } from "next/navigation"; import React, { useState } from "react"; import { Icon } from "@iconify/react"; import ProjectTransferOwnershipAPI from "@/lib/fetch/project/PUT/transferOwnership"; import ErrorCard from "@/components/error/ErrorCard"; +import { useRefreshCache } from "@/lib/swr/hooks/useRefreshCache"; export default function ProjectTransferOwnership({ disclosure, @@ -37,7 +37,7 @@ export default function ProjectTransferOwnership({ project: any; user: any; }) { - const router = useRouter(); + const { refreshProject } = useRefreshCache(); const { isOpen, onOpenChange } = disclosure; const [isLoading, setIsLoading] = useState(false); @@ -88,7 +88,7 @@ export default function ProjectTransferOwnership({ setError(false); setErrorText(""); setErrorMessage(""); - router.refresh(); + refreshProject(project.id); addToast({ title: "Project", description: "Owner transferred successfully", diff --git a/services/frontend/components/modals/runner/changeStatus.tsx b/services/frontend/components/modals/runner/changeStatus.tsx index 5cdb3de8..73498e0f 100644 --- a/services/frontend/components/modals/runner/changeStatus.tsx +++ b/services/frontend/components/modals/runner/changeStatus.tsx @@ -11,12 +11,12 @@ import { ModalHeader, Snippet, } from "@heroui/react"; -import { useRouter } from "next/navigation"; import React from "react"; import { Icon } from "@iconify/react"; import ErrorCard from "@/components/error/ErrorCard"; import ChangeRunnerStatus from "@/lib/fetch/admin/PUT/ChangeRunnerStatus"; +import { useRefreshCache } from "@/lib/swr/hooks/useRefreshCache"; export default function ChangeRunnerStatusModal({ disclosure, @@ -27,7 +27,7 @@ export default function ChangeRunnerStatusModal({ runner: any; status: any; }) { - const router = useRouter(); + const { refreshRunners } = useRefreshCache(); const { isOpen, onOpenChange } = disclosure; @@ -67,7 +67,7 @@ export default function ChangeRunnerStatusModal({ setErrorText(""); setErrorMessage(""); onOpenChange(); - router.refresh(); + refreshRunners(); addToast({ title: "Runner", description: "Runner status updated successfully", @@ -79,7 +79,7 @@ export default function ChangeRunnerStatusModal({ setError(true); setErrorText(res.error); setErrorMessage(res.message); - router.refresh(); + refreshRunners(); addToast({ title: "Runner", description: "Failed to update runner status", diff --git a/services/frontend/components/modals/runner/create.tsx b/services/frontend/components/modals/runner/create.tsx index 624a1d38..d114b57b 100644 --- a/services/frontend/components/modals/runner/create.tsx +++ b/services/frontend/components/modals/runner/create.tsx @@ -15,12 +15,12 @@ import { Snippet, useDisclosure, } from "@heroui/react"; -import { useRouter } from "next/navigation"; import React, { useState } from "react"; import { Icon } from "@iconify/react"; import AddRunner from "@/lib/fetch/runner/POST/AddRunner"; import ErrorCard from "@/components/error/ErrorCard"; +import { useRefreshCache } from "@/lib/swr/hooks/useRefreshCache"; export default function CreateRunnerModal({ disclosure, @@ -31,7 +31,7 @@ export default function CreateRunnerModal({ project: any; shared_runner: any; }) { - const router = useRouter(); + const { refreshProjectRunners } = useRefreshCache(); const { isOpen, onOpenChange } = disclosure; // instructions modal @@ -85,7 +85,7 @@ export default function CreateRunnerModal({ setInApikey(res.data.token); setInRunnerId(res.data.runner.id); onOpenChangeInstructions(); - router.refresh(); + refreshProjectRunners(project.id); addToast({ title: "Runner", description: "Runner created successfully", diff --git a/services/frontend/components/modals/runner/delete.tsx b/services/frontend/components/modals/runner/delete.tsx index 69ec9dfe..8e85f44f 100644 --- a/services/frontend/components/modals/runner/delete.tsx +++ b/services/frontend/components/modals/runner/delete.tsx @@ -14,13 +14,13 @@ import { ModalHeader, Snippet, } from "@heroui/react"; -import { useRouter } from "next/navigation"; import React, { useEffect } from "react"; import { Icon } from "@iconify/react"; import GetRunnerFlowLinks from "@/lib/fetch/runner/GetRunnerFlowLinks"; import DeleteProjectRunner from "@/lib/fetch/project/DELETE/DeleteRunner"; import ErrorCard from "@/components/error/ErrorCard"; +import { useRefreshCache } from "@/lib/swr/hooks/useRefreshCache"; export default function DeleteRunnerModal({ disclosure, @@ -29,7 +29,7 @@ export default function DeleteRunnerModal({ disclosure: UseDisclosureReturn; runner: any; }) { - const router = useRouter(); + const { refreshRunners } = useRefreshCache(); const { isOpen, onOpenChange } = disclosure; const [flowLinks, setFlowLinks] = React.useState([]); @@ -49,7 +49,7 @@ export default function DeleteRunnerModal({ setError(true); setErrorText("Failed to fetch runner flow links"); setErrorMessage("An error occurred while fetching the runner flow links"); - router.refresh(); + refreshRunners(); addToast({ title: "Runner", description: "Failed to fetch runner flow links", @@ -101,7 +101,7 @@ export default function DeleteRunnerModal({ color: "success", variant: "flat", }); - router.refresh(); + refreshRunners(); } else { setError(true); setErrorText(response.error); diff --git a/services/frontend/components/modals/runner/edit.tsx b/services/frontend/components/modals/runner/edit.tsx index d9512ece..2bafd5bb 100644 --- a/services/frontend/components/modals/runner/edit.tsx +++ b/services/frontend/components/modals/runner/edit.tsx @@ -12,12 +12,12 @@ import { ModalContent, ModalHeader, } from "@heroui/react"; -import { useRouter } from "next/navigation"; import React, { useState } from "react"; import { Icon } from "@iconify/react"; import EditRunner from "@/lib/fetch/runner/PUT/Edit"; import ErrorCard from "@/components/error/ErrorCard"; +import { useRefreshCache } from "@/lib/swr/hooks/useRefreshCache"; export default function EditRunnerModal({ disclosure, @@ -26,7 +26,7 @@ export default function EditRunnerModal({ disclosure: UseDisclosureReturn; runner: any; }) { - const router = useRouter(); + const { refreshRunners } = useRefreshCache(); const { isOpen, onOpenChange } = disclosure; const [isLoading, setIsLoading] = useState(false); @@ -71,7 +71,7 @@ export default function EditRunnerModal({ color: "success", variant: "flat", }); - router.refresh(); + refreshRunners(); } else { setApiError(true); setApiErrorText(res.error); diff --git a/services/frontend/components/modals/tokens/deleteRunnerToken.tsx b/services/frontend/components/modals/tokens/deleteRunnerToken.tsx index 1b6f0505..624fc277 100644 --- a/services/frontend/components/modals/tokens/deleteRunnerToken.tsx +++ b/services/frontend/components/modals/tokens/deleteRunnerToken.tsx @@ -12,11 +12,11 @@ import { ModalHeader, Snippet, } from "@heroui/react"; -import { useRouter } from "next/navigation"; import React from "react"; import DeleteRunnerToken from "@/lib/fetch/project/DELETE/DeleteRunnerToken"; import ErrorCard from "@/components/error/ErrorCard"; +import { useRefreshCache } from "@/lib/swr/hooks/useRefreshCache"; export default function DeleteRunnerTokenModal({ disclosure, @@ -25,7 +25,7 @@ export default function DeleteRunnerTokenModal({ disclosure: UseDisclosureReturn; token: any; }) { - const router = useRouter(); + const { refreshProjectTokens } = useRefreshCache(); const { isOpen, onOpenChange } = disclosure; const [isLoading, setIsLoading] = React.useState(false); @@ -58,7 +58,7 @@ export default function DeleteRunnerTokenModal({ setError(false); setErrorText(""); setErrorMessage(""); - router.refresh(); + refreshProjectTokens(token.project_id); onOpenChange(); addToast({ title: "Token", diff --git a/services/frontend/components/modals/tokens/edit.tsx b/services/frontend/components/modals/tokens/edit.tsx index f8618976..20dd27e8 100644 --- a/services/frontend/components/modals/tokens/edit.tsx +++ b/services/frontend/components/modals/tokens/edit.tsx @@ -12,12 +12,12 @@ import { ModalContent, ModalHeader, } from "@heroui/react"; -import { useRouter } from "next/navigation"; import React, { useState } from "react"; import { Icon } from "@iconify/react"; import UpdateToken from "@/lib/fetch/tokens/update"; import ErrorCard from "@/components/error/ErrorCard"; +import { useRefreshCache } from "@/lib/swr/hooks/useRefreshCache"; export default function EditTokenModal({ token, @@ -26,7 +26,7 @@ export default function EditTokenModal({ token: any; disclosure: UseDisclosureReturn; }) { - const router = useRouter(); + const { refreshProjectTokens } = useRefreshCache(); // create modal const { isOpen, onOpenChange, onClose } = disclosure; @@ -69,7 +69,7 @@ export default function EditTokenModal({ setApiError(false); setApiErrorText(""); setApiErrorMessage(""); - router.refresh(); + refreshProjectTokens(token.project_id); onOpenChange(); } else { setIsLoading(false); diff --git a/services/frontend/components/modals/user/welcome.tsx b/services/frontend/components/modals/user/welcome.tsx index d5d028fb..6a7f02ea 100644 --- a/services/frontend/components/modals/user/welcome.tsx +++ b/services/frontend/components/modals/user/welcome.tsx @@ -12,7 +12,6 @@ import { ModalFooter, ModalHeader, } from "@heroui/react"; -import { useRouter } from "next/navigation"; import React from "react"; import ErrorCard from "@/components/error/ErrorCard"; @@ -23,7 +22,6 @@ export default function WelcomeModal({ }: { disclosure: UseDisclosureReturn; }) { - const router = useRouter(); const { isOpen, onOpenChange } = disclosure; const [error, setError] = React.useState(false); @@ -76,7 +74,7 @@ export default function WelcomeModal({ onOpenChange={onOpenChange} > - {(onClose) => ( + {() => ( <>
From dcd02a482944688802532f72d97969b740c06138 Mon Sep 17 00:00:00 2001 From: Justin Neubert Date: Tue, 19 Aug 2025 16:33:59 +0200 Subject: [PATCH 14/20] refactor: remove unused Reloader component from AdminRunnersHeading and AdminSettingsHeading --- services/frontend/components/admin/runners/heading.tsx | 3 --- services/frontend/components/admin/settings/heading.tsx | 5 ----- 2 files changed, 8 deletions(-) diff --git a/services/frontend/components/admin/runners/heading.tsx b/services/frontend/components/admin/runners/heading.tsx index 21e53ba8..e1de23e0 100644 --- a/services/frontend/components/admin/runners/heading.tsx +++ b/services/frontend/components/admin/runners/heading.tsx @@ -4,7 +4,6 @@ import { addToast, Button, useDisclosure } from "@heroui/react"; import { Icon } from "@iconify/react"; import CreateProjectModal from "@/components/modals/projects/create"; -import Reloader from "@/components/reloader/Reloader"; import RotateSharedAutoJoinTokenModal from "@/components/modals/admin/rotateSharedAutoJoinToken"; export default function AdminRunnersHeading({ settings }: any) { @@ -61,8 +60,6 @@ export default function AdminRunnersHeading({ settings }: any) {
- - diff --git a/services/frontend/components/admin/settings/heading.tsx b/services/frontend/components/admin/settings/heading.tsx index 600c1369..f8506bfb 100644 --- a/services/frontend/components/admin/settings/heading.tsx +++ b/services/frontend/components/admin/settings/heading.tsx @@ -1,7 +1,5 @@ "use client"; -import Reloader from "@/components/reloader/Reloader"; - export default function AdminSettingsHeading() { return (
@@ -11,9 +9,6 @@ export default function AdminSettingsHeading() { Admin | exFlow Settings

-
- -
); From 4b8f673d57435248d746bfa4517d0c109d3e24d9 Mon Sep 17 00:00:00 2001 From: Justin Neubert Date: Tue, 19 Aug 2025 11:46:25 +0200 Subject: [PATCH 15/20] feat: add Flow Failure Pipelines component with drag-and-drop functionality for actions - Implemented FlowFailurePipelines component to manage failure pipelines. - Integrated drag-and-drop sorting for actions within failure pipelines. - Added modals for creating, editing, deleting, and managing actions in failure pipelines. - Updated FlowTabs to include a new tab for Failure Pipelines. - Adjusted FlowHeading to improve button accessibility and layout. - Modified ProjectsList to enhance project card layout and added footer with creation date. - Updated EditFailurePipelineModal size for better user experience. --- .../execution/adminExecutionActions.tsx | 2 +- .../executions/execution/execution.tsx | 6 +- .../components/flows/flow/actions.tsx | 852 +++------------ .../flows/flow/failure-pipelines.tsx | 983 ++++++++++++++++++ .../components/flows/flow/heading.tsx | 2 +- .../frontend/components/flows/flow/tabs.tsx | 23 +- .../modals/failurePipelines/edit.tsx | 2 +- .../frontend/components/projects/list.tsx | 146 +-- 8 files changed, 1223 insertions(+), 793 deletions(-) create mode 100644 services/frontend/components/flows/flow/failure-pipelines.tsx diff --git a/services/frontend/components/executions/execution/adminExecutionActions.tsx b/services/frontend/components/executions/execution/adminExecutionActions.tsx index fe406aeb..bc308b77 100644 --- a/services/frontend/components/executions/execution/adminExecutionActions.tsx +++ b/services/frontend/components/executions/execution/adminExecutionActions.tsx @@ -121,7 +121,7 @@ export default function AdminExecutionActions({ diff --git a/services/frontend/components/executions/execution/execution.tsx b/services/frontend/components/executions/execution/execution.tsx index 0263d446..5dfd84f5 100644 --- a/services/frontend/components/executions/execution/execution.tsx +++ b/services/frontend/components/executions/execution/execution.tsx @@ -56,11 +56,7 @@ export function Execution({ flow, execution, runners, userDetails }: any) { return ( <>
- diff --git a/services/frontend/components/flows/flow/actions.tsx b/services/frontend/components/flows/flow/actions.tsx index ec136c62..c473b28d 100644 --- a/services/frontend/components/flows/flow/actions.tsx +++ b/services/frontend/components/flows/flow/actions.tsx @@ -8,8 +8,6 @@ import { import { CSS } from "@dnd-kit/utilities"; import { Icon } from "@iconify/react"; import { - Accordion, - AccordionItem, addToast, Alert, Button, @@ -23,7 +21,6 @@ import { DropdownItem, DropdownMenu, DropdownTrigger, - ScrollShadow, Snippet, Spacer, Switch, @@ -39,17 +36,12 @@ import { useDisclosure, } from "@heroui/react"; import React, { useEffect } from "react"; -import { useRouter } from "next/navigation"; import UpdateFlowActions from "@/lib/fetch/flow/PUT/UpdateActions"; import EditFlowActionsDetails from "@/components/modals/actions/editDetails"; import EditActionModal from "@/components/modals/actions/edit"; import DeleteActionModal from "@/components/modals/actions/delete"; import AddActionModal from "@/components/modals/actions/add"; -import CreateFailurePipelineModal from "@/components/modals/failurePipelines/create"; -import DeleteFailurePipelineModal from "@/components/modals/failurePipelines/delete"; -import EditFailurePipelineModal from "@/components/modals/failurePipelines/edit"; -import UpdateFlowFailurePipelineActions from "@/lib/fetch/flow/PUT/UpdateFailurePipelineActions"; import CopyActionModal from "@/components/modals/actions/copy"; import UpgradeActionModal from "@/components/modals/actions/upgrade"; import CopyActionToDifferentFlowModal from "@/components/modals/actions/transferCopy"; @@ -71,56 +63,23 @@ export default function Actions({ canEdit: boolean; settings: any; }) { - const router = useRouter(); - const [actions, setActions] = React.useState([] as any); const [targetAction, setTargetAction] = React.useState({} as any); const [updatedAction, setUpdatedAction] = React.useState({} as any); const [showDefaultParams, setShowDefaultParams] = React.useState(false); - const [failurePipelines, setFailurePipelines] = React.useState([] as any); - const [targetFailurePipeline, setTargetFailurePipeline] = React.useState( - {} as any, - ); - - const [failurePipelineTab, setFailurePipelineTab] = - React.useState("add-pipeline"); - const editFlowActionsDetails = useDisclosure(); const addFlowActionModal = useDisclosure(); const editActionModal = useDisclosure(); const copyFlowActionModal = useDisclosure(); const upgradeFlowActionModal = useDisclosure(); const deleteActionModal = useDisclosure(); - const createFlowFailurePipelineModal = useDisclosure(); - const editFlowFailurePipelineModal = useDisclosure(); - const deleteFailurePipelineModal = useDisclosure(); - const addFlowFailurePipelineActionModal = useDisclosure(); - const editFlowFailurePipelineActionModal = useDisclosure(); - const deleteFlowFailurePipelineActionModal = useDisclosure(); - const copyFlowFailurePipelineActionModal = useDisclosure(); - const upgradeFlowFailurePipelineActionModal = useDisclosure(); const copyActionToDifferentFlowModal = useDisclosure(); - const copyFailurePipelineActionToDifferentFlowModal = useDisclosure(); - - const [expandedParams, setExpandedParams] = React.useState([] as any); useEffect(() => { setActions(flow.actions); - - if (flow.failure_pipelines !== null) { - setFailurePipelines(flow.failure_pipelines); - - if (failurePipelineTab === "add-pipeline") { - setFailurePipelineTab(flow.failure_pipelines[0]?.id || "add-pipeline"); - } - } - }, [flow]); - - const handleFailurePipelineTabChange = (key: any) => { - setFailurePipelineTab(key); - }; + }, [flow.actions]); // function to get action from clipboard const getClipboardAction = async () => { @@ -213,46 +172,7 @@ export default function Actions({
- - + + } onPress={() => { navigator.clipboard.writeText( @@ -284,91 +205,55 @@ export default function Actions({ }); }} > - Copy to Clipboard + Clipboard } onPress={() => { - // if action is in an failure pipeline, open the edit modal - if ( - flow.failure_pipelines.some( - (pipeline: any) => - pipeline.actions !== null && - pipeline.actions.some( - (pipelineAction: any) => - pipelineAction.id === action.id, - ), - ) - ) { - setTargetAction(action); - setTargetFailurePipeline( - flow.failure_pipelines.filter( - (pipeline: any) => - pipeline.actions !== null && - pipeline.actions.some( - (pipelineAction: any) => - pipelineAction.id === action.id, - ), - )[0], - ); - copyFlowFailurePipelineActionModal.onOpen(); - } else { - setTargetAction(action); - copyFlowActionModal.onOpen(); - } + setTargetAction(action); + copyFlowActionModal.onOpen(); }} > - Copy Locally + Local } onPress={() => { - // if action is in an failure pipeline, open the edit modal - if ( - flow.failure_pipelines.some( - (pipeline: any) => - pipeline.actions !== null && - pipeline.actions.some( - (pipelineAction: any) => - pipelineAction.id === action.id, - ), - ) - ) { - setTargetAction(action); - setTargetFailurePipeline( - flow.failure_pipelines.filter( - (pipeline: any) => - pipeline.actions !== null && - pipeline.actions.some( - (pipelineAction: any) => - pipelineAction.id === action.id, - ), - )[0], - ); - copyFailurePipelineActionToDifferentFlowModal.onOpen(); - } else { - setTargetAction(action); - copyActionToDifferentFlowModal.onOpen(); - } + setTargetAction(action); + copyActionToDifferentFlowModal.onOpen(); }} > - Copy to another Flow + Transfer + @@ -415,11 +275,12 @@ export default function Actions({ isDisabled={ (!canEdit || flow.disabled) && user.role !== "admin" } + size="sm" variant="flat" {...listeners} style={{ cursor: "grab", touchAction: "none" }} > - +
@@ -466,74 +327,43 @@ export default function Actions({ ))} - {action.update_available && ( - + {action.update_available && ( + + + description="Newer plugin version was found on one of the runners. Do you want to upgrade the action plugin version?" + endContent={ + } - variant="flat" - onPress={() => { - // if action is in an failure pipeline, open the edit modal - if ( - flow.failure_pipelines.some( - (pipeline: any) => - pipeline.actions !== null && - pipeline.actions.some( - (pipelineAction: any) => - pipelineAction.id === action.id, - ), - ) - ) { - setTargetAction(action); - setUpdatedAction(action.updated_action); - setTargetFailurePipeline( - flow.failure_pipelines.filter( - (pipeline: any) => - pipeline.actions !== null && - pipeline.actions.some( - (pipelineAction: any) => - pipelineAction.id === action.id, - ), - )[0], - ); - upgradeFlowFailurePipelineActionModal.onOpen(); - } else { - setTargetAction(action); - setUpdatedAction(action.updated_action); - upgradeFlowActionModal.onOpen(); - } - }} - > - Upgrade - - } - title={`Update to version ${action.update_version} available`} - variant="faded" - /> - )} - - + title={`Update to version ${action.update_version} available`} + variant="faded" + /> + + )} +
- +
{action.params.length > 0 && ( - +
- + )} {action.condition.selected_action_id !== "" && ( - +

Options

- + )} - +
@@ -780,409 +600,82 @@ export default function Actions({ }); } - const handleDragEndPipeline = (pipeline: any, event: any) => { - const { active, over } = event; - - if (active.id !== over.id) { - const items = [...pipeline.actions]; - const oldIndex = items.findIndex((item: any) => item.id === active.id); - const newIndex = items.findIndex((item: any) => item.id === over.id); - - const newArray = arrayMove(items, oldIndex, newIndex); - - updateFlowFailurePipelineActions(pipeline, newArray); - } - }; - - function updateFlowFailurePipelineActions(pipeline: any, actions: any) { - UpdateFlowFailurePipelineActions(flow.id, pipeline.id, actions) - .then(() => { - router.refresh(); - addToast({ - title: "Flow", - description: - "Flow failure pipeline actions order updated successfully.", - color: "success", - variant: "flat", - }); - }) - .catch(() => { - router.refresh(); - addToast({ - title: "Flow", - description: "Failed to update flow failure pipeline actions order.", - color: "danger", - variant: "flat", - }); - }); - } - return (
-

- Info: Common action settings can be found on the settings tab -

- -
-
- - - -
- {actions.map((action: any) => ( - - ))} -
-
-
-
- -
- - -
-
-
- -
-
-

Create new Action

-

- Add a new action to the flow -

-
-
-
-
-
- - { - const parsedAction = await getClipboardAction(); - - if (parsedAction) { - setTargetAction(parsedAction); - copyFlowActionModal.onOpen(); - } else { - addToast({ - title: "Flow", - description: "No action found in clipboard.", - color: "danger", - variant: "flat", - }); - } - }} - > - -
-
-
- -
-
-

- Paste Action from Clipboard -

-

- You have an action copied to the clipboard. -

-
-
-
-
-
-
-
- -
-
- -
-

Failure Pipelines

-

- With failure pipelines you have the ability to send - notifications or trigger any other action if a specific action - or the whole execution failed. + + +

+
+

Actions

+

+ Common action settings can be found on the settings tab

-
- - {failurePipelines.map((pipeline: any) => ( - -
- - -
-
-
-

{pipeline.name}

-
- - {pipeline.exec_parallel - ? "Parallel" - : "Sequential"} - - - action.failure_pipeline_id === - pipeline.id, - ).length > 0 - ? "success" - : "danger" - } - radius="sm" - size="sm" - variant="flat" - > - {flow.failure_pipeline_id === pipeline.id - ? "Assigned to Flow" - : flow.actions.filter( - (action: any) => - action.failure_pipeline_id === - pipeline.id, - ).length > 0 - ? "Assigned on Action" - : "Not Assigned"} - -
-
-

- {pipeline.id} -

-
-
- - -
-
-
-
- - - handleDragEndPipeline(pipeline, event) - } - > - -
- {pipeline.actions !== null && - pipeline.actions.length > 0 && - pipeline.actions.map((action: any) => ( - - ))} -
-
-
-
- -
- { - setTargetFailurePipeline(pipeline); - addFlowFailurePipelineActionModal.onOpen(); - }} - > - -
-
-
- -
-
-

- Create new Action -

-

- Add a new action to the failure pipeline -

-
-
-
-
-
- - { - const parsedAction = await getClipboardAction(); - - if (parsedAction) { - setTargetAction(parsedAction); - setTargetFailurePipeline(pipeline); - copyFlowFailurePipelineActionModal.onOpen(); - } else { - addToast({ - title: "Flow", - description: "No action found in clipboard.", - color: "danger", - variant: "flat", - }); - } - }} - > - -
-
-
- -
-
-

- Paste Action from Clipboard -

-

- You have an action copied to the clipboard. -

-
-
-
-
-
-
-
-
- ))} - + - } - /> -
- {flow.failure_pipelines !== null && - flow.failure_pipelines.length === 0 && ( -
-

- No failure pipelines defined. -

-
- )} -
+ /> + +
+
+ + + +
+ + +
+ {actions.map((action: any) => ( + + ))} +
+
+
- - - - - - - - - - -
); } diff --git a/services/frontend/components/flows/flow/failure-pipelines.tsx b/services/frontend/components/flows/flow/failure-pipelines.tsx new file mode 100644 index 00000000..ca53ffac --- /dev/null +++ b/services/frontend/components/flows/flow/failure-pipelines.tsx @@ -0,0 +1,983 @@ +import { closestCenter, DndContext } from "@dnd-kit/core"; +import { + arrayMove, + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { Icon } from "@iconify/react"; +import { + Accordion, + AccordionItem, + addToast, + Alert, + Button, + ButtonGroup, + Card, + CardBody, + Checkbox, + Chip, + Divider, + Dropdown, + DropdownItem, + DropdownMenu, + DropdownTrigger, + ScrollShadow, + Snippet, + Spacer, + Switch, + Tab, + Table, + TableBody, + TableCell, + TableColumn, + TableHeader, + TableRow, + Tabs, + Tooltip, + useDisclosure, +} from "@heroui/react"; +import React, { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +import EditActionModal from "@/components/modals/actions/edit"; +import DeleteActionModal from "@/components/modals/actions/delete"; +import AddActionModal from "@/components/modals/actions/add"; +import CreateFailurePipelineModal from "@/components/modals/failurePipelines/create"; +import DeleteFailurePipelineModal from "@/components/modals/failurePipelines/delete"; +import EditFailurePipelineModal from "@/components/modals/failurePipelines/edit"; +import UpdateFlowFailurePipelineActions from "@/lib/fetch/flow/PUT/UpdateFailurePipelineActions"; +import CopyActionModal from "@/components/modals/actions/copy"; +import UpgradeActionModal from "@/components/modals/actions/upgrade"; +import CopyActionToDifferentFlowModal from "@/components/modals/actions/transferCopy"; + +export default function FlowFailurePipelines({ + projects, + flows, + flow, + runners, + user, + canEdit, + settings, +}: { + projects: any; + flows: any; + flow: any; + runners: any; + user: any; + canEdit: boolean; + settings: any; +}) { + const router = useRouter(); + + const [targetAction, setTargetAction] = React.useState({} as any); + const [updatedAction, setUpdatedAction] = React.useState({} as any); + + const [showDefaultParams, setShowDefaultParams] = React.useState(false); + + const [failurePipelines, setFailurePipelines] = React.useState([] as any); + const [targetFailurePipeline, setTargetFailurePipeline] = React.useState( + {} as any, + ); + + const [failurePipelineTab, setFailurePipelineTab] = + React.useState("add-pipeline"); + + const createFlowFailurePipelineModal = useDisclosure(); + const editFlowFailurePipelineModal = useDisclosure(); + const deleteFailurePipelineModal = useDisclosure(); + const addFlowFailurePipelineActionModal = useDisclosure(); + const editFlowFailurePipelineActionModal = useDisclosure(); + const deleteFlowFailurePipelineActionModal = useDisclosure(); + const copyFlowFailurePipelineActionModal = useDisclosure(); + const upgradeFlowFailurePipelineActionModal = useDisclosure(); + const copyActionToDifferentFlowModal = useDisclosure(); + const copyFailurePipelineActionToDifferentFlowModal = useDisclosure(); + + const [expandedParams, setExpandedParams] = React.useState([] as any); + + useEffect(() => { + if (flow.failure_pipelines !== null) { + setFailurePipelines(flow.failure_pipelines); + + if (failurePipelineTab === "add-pipeline") { + setFailurePipelineTab(flow.failure_pipelines[0]?.id || "add-pipeline"); + } + } + }, [flow]); + + const handleFailurePipelineTabChange = (key: any) => { + setFailurePipelineTab(key); + }; + + // function to get action from clipboard + const getClipboardAction = async () => { + try { + const clipboardText = await navigator.clipboard.readText(); + const parsedAction = JSON.parse(clipboardText); + + if (parsedAction && parsedAction.id && parsedAction.plugin) { + return parsedAction; + } else { + return null; + } + } catch { + return null; + } + }; + + const SortableItem = ({ action }: { action: any }) => { + const { attributes, listeners, setNodeRef, transform, transition } = + useSortable({ id: action.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ + +
+
+
+
+
+ +
+
+
+

+ {action.custom_name + ? action.custom_name + : action.name} +

+ + Vers. {action.version} + + + {action.active ? "Active" : "Disabled"} + + {flow.failure_pipeline_id !== "" || + (flow.failure_pipeline_id !== null && + !flow.failure_pipelines.some( + (pipeline: any) => + pipeline.id === action.failure_pipeline_id || + (pipeline.actions !== null && + pipeline.actions.some( + (pipelineAction: any) => + pipelineAction.id === action.id, + )), + ) && ( + + No Failure Pipeline Assigned + + ))} +
+

+ {action.custom_description + ? action.custom_description + : action.description} +

+
+
+
+ + + + + + + + } + onPress={() => { + navigator.clipboard.writeText( + JSON.stringify(action), + ); + addToast({ + title: "Action", + description: "Action copied to clipboard!", + color: "success", + variant: "flat", + }); + }} + > + Clipboard + + + } + onPress={() => { + setTargetAction(action); + setTargetFailurePipeline( + flow.failure_pipelines.filter( + (pipeline: any) => + pipeline.actions !== null && + pipeline.actions.some( + (pipelineAction: any) => + pipelineAction.id === action.id, + ), + )[0], + ); + copyFlowFailurePipelineActionModal.onOpen(); + }} + > + Local + + + } + onPress={() => { + // if action is in an failure pipeline, open the edit modal + if ( + flow.failure_pipelines.some( + (pipeline: any) => + pipeline.actions !== null && + pipeline.actions.some( + (pipelineAction: any) => + pipelineAction.id === action.id, + ), + ) + ) { + setTargetAction(action); + setTargetFailurePipeline( + flow.failure_pipelines.filter( + (pipeline: any) => + pipeline.actions !== null && + pipeline.actions.some( + (pipelineAction: any) => + pipelineAction.id === action.id, + ), + )[0], + ); + copyFailurePipelineActionToDifferentFlowModal.onOpen(); + } else { + setTargetAction(action); + copyActionToDifferentFlowModal.onOpen(); + } + }} + > + Transfer + + + + + + + + + +
+
+ +
+ + {action.active ? "Active" : "Disabled"} + + + Vers. {action.version} + + {flow.failure_pipeline_id !== "" || + (flow.failure_pipeline_id !== null && + !flow.failure_pipelines.some( + (pipeline: any) => + pipeline.id === action.failure_pipeline_id || + (pipeline.actions !== null && + pipeline.actions.some( + (pipelineAction: any) => + pipelineAction.id === action.id, + )), + ) && ( + + No Failure Pipeline Assigned + + ))} +
+ {action.update_available && ( + + } + variant="flat" + onPress={() => { + setTargetAction(action); + setUpdatedAction(action.updated_action); + setTargetFailurePipeline( + flow.failure_pipelines.filter( + (pipeline: any) => + pipeline.actions !== null && + pipeline.actions.some( + (pipelineAction: any) => + pipelineAction.id === action.id, + ), + )[0], + ); + upgradeFlowFailurePipelineActionModal.onOpen(); + }} + > + Upgrade + + } + title={`Update to version ${action.update_version} available`} + variant="faded" + /> + )} + + + + + Name + Value + + + + ID + + + {action.id} + + + + + Plugin + {action.plugin} + + + Plugin Name + {action.name} + + + Plugin Description + {action.description} + + + Failure Pipeline + + {flow.failure_pipeline_id === "" || + flow.failure_pipeline_id === null ? ( + flow.failure_pipelines.filter( + (pipeline: any) => + pipeline.id === action.failure_pipeline_id, + )[0]?.name || + action.failure_pipeline_id || + "None" + ) : ( + + Overwritten by Flow Setting + + )} + + + +
+
+ {action.params.length > 0 && ( + +
+ + Show default parameters + +
+ + + Key + Value + Note + + + {action.params + .filter( + (param: any) => + showDefaultParams || + param.value !== param.default, + ) + .map((param: any, index: number) => ( + + {param.key} + + {param.type === "password" + ? "••••••••" + : param.value} + + + {param.type === "password" && + param.value != "" ? ( + + Encrypted + + ) : ( + "" + )} + + + ))} + +
+
+ )} + {action.condition.selected_action_id !== "" && ( + +
+

Options

+ + Cancel{" "} + Execution if conditions match and dont start any + following action. + +
+ +
+
+ + a.id === action.condition.selected_action_id, + )[0]?.icon + } + width={26} + /> +
+
+
+

+ {flow.actions.filter( + (a: any) => + a.id === action.condition.selected_action_id, + )[0]?.custom_name || + flow.actions.filter( + (a: any) => + a.id === + action.condition.selected_action_id, + )[0]?.name || + action.condition.selected_action_id} +

+
+

+ {flow.actions.filter( + (a: any) => + a.id === action.condition.selected_action_id, + )[0]?.custom_description || + flow.actions.filter( + (a: any) => + a.id === action.condition.selected_action_id, + )[0]?.description || + "No description available"} +

+
+
+ + + Key + Type + Value + Logic + + + {action.condition.condition_items.map( + (condition: any, index: number) => ( + + {condition.condition_key} + + {condition.condition_type} + + + {condition.condition_value} + + + {condition.condition_logic === "and" + ? "&" + : "or"} + + + ), + )} + +
+
+ )} +
+
+
+
+
+
+ ); + }; + + const handleDragEndPipeline = (pipeline: any, event: any) => { + const { active, over } = event; + + if (active.id !== over.id) { + const items = [...pipeline.actions]; + const oldIndex = items.findIndex((item: any) => item.id === active.id); + const newIndex = items.findIndex((item: any) => item.id === over.id); + + const newArray = arrayMove(items, oldIndex, newIndex); + + updateFlowFailurePipelineActions(pipeline, newArray); + } + }; + + function updateFlowFailurePipelineActions(pipeline: any, actions: any) { + UpdateFlowFailurePipelineActions(flow.id, pipeline.id, actions) + .then(() => { + router.refresh(); + addToast({ + title: "Flow", + description: + "Flow failure pipeline actions order updated successfully.", + color: "success", + variant: "flat", + }); + }) + .catch(() => { + router.refresh(); + addToast({ + title: "Flow", + description: "Failed to update flow failure pipeline actions order.", + color: "danger", + variant: "flat", + }); + }); + } + + return ( +
+

+ With failure pipelines you have the ability to send notifications or + trigger any other action if a specific action or the whole execution + failed. +

+ + + {failurePipelines.map((pipeline: any) => ( + +
+ + +
+
+
+

{pipeline.name}

+
+ + {pipeline.exec_parallel ? "Parallel" : "Sequential"} + + + action.failure_pipeline_id === + pipeline.id, + ).length > 0 + ? "success" + : "danger" + } + radius="sm" + size="sm" + variant="flat" + > + {flow.failure_pipeline_id === pipeline.id + ? "Assigned to Flow" + : flow.actions.filter( + (action: any) => + action.failure_pipeline_id === + pipeline.id, + ).length > 0 + ? "Assigned on Step" + : "Not Assigned"} + +
+
+

+ {pipeline.id} +

+
+
+ +
+
+
+
+ + handleDragEndPipeline(pipeline, event)} + > + +
+ {pipeline.actions !== null && + pipeline.actions.length > 0 && + pipeline.actions.map((action: any) => ( + + ))} +
+
+
+
+
+
+ ))} + { + createFlowFailurePipelineModal.onOpen(); + }} + > + + + } + /> +
+ + {flow.failure_pipelines !== null && + flow.failure_pipelines.length === 0 && ( +
+

+ No failure pipelines defined. +

+
+ )} + + + + + + + + + + + +
+ ); +} diff --git a/services/frontend/components/flows/flow/heading.tsx b/services/frontend/components/flows/flow/heading.tsx index 6c76e35c..5073086d 100644 --- a/services/frontend/components/flows/flow/heading.tsx +++ b/services/frontend/components/flows/flow/heading.tsx @@ -57,7 +57,6 @@ export default function FlowHeading({
} @@ -78,6 +78,25 @@ export default function FlowTabs({ user={user} /> + + + Failure Pipelines +
+ } + > + + diff --git a/services/frontend/components/projects/list.tsx b/services/frontend/components/projects/list.tsx index e9545b37..bd0ada08 100644 --- a/services/frontend/components/projects/list.tsx +++ b/services/frontend/components/projects/list.tsx @@ -6,6 +6,7 @@ import { ButtonGroup, Card, CardBody, + CardFooter, Chip, Dropdown, DropdownItem, @@ -141,7 +142,7 @@ export function ProjectsList({ projects, pending_projects, user }: any) { router.push(`/projects/${project.id}`); }} > - +
-
-

{project.name}

- - {project.disabled ? "Disabled" : "Enabled"} - -
+

{project.name}

{project.description}

- - - - - - - } - onPress={() => copyProjectIDtoClipboard(project.id)} - > - Copy ID - - - } - onPress={() => { - setTargetProject(project); - editProjectModal.onOpen(); - }} - > - Edit - - - } - onPress={() => { - setTargetProject(project); - deleteProjectModal.onOpen(); - }} - > - Delete - - - + + {project.disabled ? "Disabled" : "Enabled"} +
+ +

+ Created At:{" "} + {new Date(project.created_at).toLocaleString() || "Unknown"} +

+ + + + + + } + onPress={() => copyProjectIDtoClipboard(project.id)} + > + Copy ID + + + } + onPress={() => { + setTargetProject(project); + editProjectModal.onOpen(); + }} + > + Edit + + + } + onPress={() => { + setTargetProject(project); + deleteProjectModal.onOpen(); + }} + > + Delete + + + +
))}
From f5159455b31608a64d1ccd69ff4d8d9198459dc2 Mon Sep 17 00:00:00 2001 From: Justin Neubert Date: Tue, 19 Aug 2025 16:49:47 +0200 Subject: [PATCH 16/20] refactor: streamline FlowHeading and FlowSettings components for improved layout and consistency --- .../components/flows/flow/heading.tsx | 4 +- .../components/flows/flow/settings.tsx | 133 +++++++++--------- .../frontend/components/flows/flow/tabs.tsx | 26 +--- 3 files changed, 77 insertions(+), 86 deletions(-) diff --git a/services/frontend/components/flows/flow/heading.tsx b/services/frontend/components/flows/flow/heading.tsx index 5073086d..26123e4f 100644 --- a/services/frontend/components/flows/flow/heading.tsx +++ b/services/frontend/components/flows/flow/heading.tsx @@ -94,9 +94,7 @@ export default function FlowHeading({ onPress={() => { editFlowModal.onOpen(); }} - > - Edit - + /> {/* Mobile */} diff --git a/services/frontend/components/flows/flow/settings.tsx b/services/frontend/components/flows/flow/settings.tsx index 8a37eaf4..e6fc9e4a 100644 --- a/services/frontend/components/flows/flow/settings.tsx +++ b/services/frontend/components/flows/flow/settings.tsx @@ -84,14 +84,14 @@ export default function FlowSettings({ return ( <> {error && } -
-
-

Actions

-
- - -
-
+
+ + +

Actions

+
+ + +

Execution Strategy

Switch between parallel and sequential execution of @@ -104,6 +104,7 @@ export default function FlowSettings({ } placeholder="Select the execution strategy" selectedKeys={[execParallel ? "parallel" : "sequential"]} + variant="bordered" onSelectionChange={(e) => { if (e.currentKey === "parallel") { setExecParallel(true); @@ -115,14 +116,12 @@ export default function FlowSettings({ Sequential Parallel -

-
-
+ + - - -
-
+ + +

Common Failure Pipeline

Execute an failure pipeline when actions during an @@ -140,6 +139,7 @@ export default function FlowSettings({ } placeholder="Select an failure pipeline" selectedKeys={[failurePipelineID]} + variant="bordered" onSelectionChange={(e) => { if (e.currentKey === "none") { setFailurePipelineID(""); @@ -153,56 +153,61 @@ export default function FlowSettings({ {pipeline.name} ))} -

-
-
-
-
-
-

Executions

-
- - -
-
-

Schedule Every

-

- Schedule the flow to run every X minutes/hours/days.{" "} -
- The system will always schedule two executions at the - time. The second one will be scheduled base on the - scheduled time of the first one. -
- - Enter 0 to disable the schedule. - -

-
-
- - + + +
+ + + + + +

Executions

+
+ + +
+
+

Schedule Every

+

+ Schedule the flow to run every X minutes/hours/days.{" "} +
+ The system will always schedule two executions at the + time. The second one will be scheduled base on the + scheduled time of the first one. +
+ + Enter 0 to disable the schedule. + +

+
+
+ + +
-
-
-
-
-
+ + +
+
+
} > + + - - - Info -
- } - > - -
From 12cd2fab3a164c81d3d96b9c28e5942e7af489c3 Mon Sep 17 00:00:00 2001 From: Justin Neubert Date: Thu, 21 Aug 2025 11:28:43 +0200 Subject: [PATCH 17/20] feat: Update FlowHeading component to remove secondary color from button feat: Enhance FlowSettings component to disable inputs based on user role and edit permissions feat: Add FlowActionDetails component for displaying action details in a drawer style: Update Navbar component with new background and button radius refactor: Simplify ProjectsList component by integrating new member management features and dropdown for project invitations fix: Adjust Project component to ensure edit button is icon-only fix: Update ProjectRunnerDetails component to correctly handle edit permissions feat: Create ProjectMembers component for managing project members with modals for adding, editing, and removing members delete: Remove unused UserTable component fix: Update project tabs to use new ProjectMembers component style: Enhance Search component button with new icon and full radius feat: Implement ShineBorder component for animated border effects chore: Bump version to 2.0.0 in site configuration style: Add shine animation to Tailwind CSS configuration --- .../components/flows/flow/actions.tsx | 667 +++++---------- .../flows/flow/failure-pipelines.tsx | 786 ++++++------------ .../components/flows/flow/heading.tsx | 1 - .../components/flows/flow/settings.tsx | 6 + .../components/modals/actions/details.tsx | 238 ++++++ services/frontend/components/navbar.tsx | 3 +- .../frontend/components/projects/list.tsx | 234 ++++-- .../frontend/components/projects/project.tsx | 12 +- .../projects/project/RunnerDetails.tsx | 6 +- .../components/projects/project/members.tsx | 242 ++++++ .../projects/project/tables/UserTable.tsx | 284 ------- .../components/projects/project/tabs.tsx | 2 +- .../frontend/components/search/search.tsx | 14 +- .../frontend/components/ui/shine-border.tsx | 62 ++ services/frontend/config/site.ts | 2 +- services/frontend/tailwind.config.ts | 12 + 16 files changed, 1196 insertions(+), 1375 deletions(-) create mode 100644 services/frontend/components/modals/actions/details.tsx create mode 100644 services/frontend/components/projects/project/members.tsx delete mode 100644 services/frontend/components/projects/project/tables/UserTable.tsx create mode 100644 services/frontend/components/ui/shine-border.tsx diff --git a/services/frontend/components/flows/flow/actions.tsx b/services/frontend/components/flows/flow/actions.tsx index c473b28d..b2f33b6a 100644 --- a/services/frontend/components/flows/flow/actions.tsx +++ b/services/frontend/components/flows/flow/actions.tsx @@ -9,29 +9,17 @@ import { CSS } from "@dnd-kit/utilities"; import { Icon } from "@iconify/react"; import { addToast, - Alert, Button, ButtonGroup, Card, CardBody, - Checkbox, + CardFooter, Chip, - Divider, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, - Snippet, Spacer, - Switch, - Tab, - Table, - TableBody, - TableCell, - TableColumn, - TableHeader, - TableRow, - Tabs, Tooltip, useDisclosure, } from "@heroui/react"; @@ -45,6 +33,7 @@ import AddActionModal from "@/components/modals/actions/add"; import CopyActionModal from "@/components/modals/actions/copy"; import UpgradeActionModal from "@/components/modals/actions/upgrade"; import CopyActionToDifferentFlowModal from "@/components/modals/actions/transferCopy"; +import FlowActionDetails from "@/components/modals/actions/details"; export default function Actions({ projects, @@ -67,8 +56,7 @@ export default function Actions({ const [targetAction, setTargetAction] = React.useState({} as any); const [updatedAction, setUpdatedAction] = React.useState({} as any); - const [showDefaultParams, setShowDefaultParams] = React.useState(false); - + const viewFlowActionDetails = useDisclosure(); const editFlowActionsDetails = useDisclosure(); const addFlowActionModal = useDisclosure(); const editActionModal = useDisclosure(); @@ -108,458 +96,212 @@ export default function Actions({ return (
- + { + setTargetAction(action); + viewFlowActionDetails.onOpen(); + }} + > -
-
-
-
-
- -
-
-
-

- {action.custom_name - ? action.custom_name - : action.name} -

- - Vers. {action.version} - - - {action.active ? "Active" : "Disabled"} - - {flow.failure_pipeline_id !== "" || - (flow.failure_pipeline_id !== null && - !flow.failure_pipelines.some( - (pipeline: any) => - pipeline.id === action.failure_pipeline_id || - (pipeline.actions !== null && - pipeline.actions.some( - (pipelineAction: any) => - pipelineAction.id === action.id, - )), - ) && ( - - No Failure Pipeline Assigned - - ))} -
-

- {action.custom_description - ? action.custom_description - : action.description} -

-
-
-
- - - - - - - - } - onPress={() => { - navigator.clipboard.writeText( - JSON.stringify(action), - ); - addToast({ - title: "Action", - description: "Action copied to clipboard!", - color: "success", - variant: "flat", - }); - }} - > - Clipboard - - - } - onPress={() => { - setTargetAction(action); - copyFlowActionModal.onOpen(); - }} - > - Local - - - } - onPress={() => { - setTargetAction(action); - copyActionToDifferentFlowModal.onOpen(); - }} - > - Transfer - - - - - - - - - -
+
+
+
+
- -
- +

+ {action.custom_name ? action.custom_name : action.name} +

+

+ {action.custom_description + ? action.custom_description + : action.description} +

+
+
+ + + +
+ + +
+ + Vers. {action.version} + + + {action.active ? "Active" : "Disabled"} + + {flow.failure_pipeline_id !== "" || + (flow.failure_pipeline_id !== null && + !flow.failure_pipelines.some( + (pipeline: any) => + pipeline.id === action.failure_pipeline_id || + (pipeline.actions !== null && + pipeline.actions.some( + (pipelineAction: any) => + pipelineAction.id === action.id, + )), + ) && ( + + No Failure Pipeline Assigned + + ))} + {action.update_available && ( + + Upgrade Available + + )} +
+
+ + {action.update_available && ( + + + + )} + + + + + + + + + + } + onPress={() => { + navigator.clipboard.writeText(JSON.stringify(action)); + addToast({ + title: "Action", + description: "Action copied to clipboard!", + color: "success", + variant: "flat", + }); + }} + > + Clipboard + + + } + onPress={() => { + setTargetAction(action); + copyFlowActionModal.onOpen(); + }} + > + Local + + + } + onPress={() => { + setTargetAction(action); + copyActionToDifferentFlowModal.onOpen(); + }} + > + Transfer + + + + +
- - {action.update_available && ( - - - } - variant="flat" - onPress={() => { - setTargetAction(action); - setUpdatedAction(action.updated_action); - upgradeFlowActionModal.onOpen(); - }} - > - Upgrade - - } - title={`Update to version ${action.update_version} available`} - variant="faded" - /> - - )} - - - - Name - Value - - - - ID - - - {action.id} - - - - - Plugin - {action.plugin} - - - Plugin Name - {action.name} - - - Plugin Description - {action.description} - - - Failure Pipeline - - {flow.failure_pipeline_id === "" || - flow.failure_pipeline_id === null ? ( - flow.failure_pipelines.filter( - (pipeline: any) => - pipeline.id === action.failure_pipeline_id, - )[0]?.name || - action.failure_pipeline_id || - "None" - ) : ( - - Overwritten by Flow Setting - - )} - - - -
-
- {action.params.length > 0 && ( - -
- - Show default parameters - -
- - - Key - Value - Note - - - {action.params - .filter( - (param: any) => - showDefaultParams || - param.value !== param.default, - ) - .map((param: any, index: number) => ( - - {param.key} - - {param.type === "password" - ? "••••••••" - : param.value} - - - {param.type === "password" && - param.value != "" ? ( - - Encrypted - - ) : ( - "" - )} - - - ))} - -
-
- )} - {action.condition.selected_action_id !== "" && ( - -
-

Options

- - Cancel{" "} - Execution if conditions match and dont start any - following action. - -
- -
-
- - a.id === action.condition.selected_action_id, - )[0]?.icon - } - width={26} - /> -
-
-
-

- {flow.actions.filter( - (a: any) => - a.id === action.condition.selected_action_id, - )[0]?.custom_name || - flow.actions.filter( - (a: any) => - a.id === - action.condition.selected_action_id, - )[0]?.name || - action.condition.selected_action_id} -

-
-

- {flow.actions.filter( - (a: any) => - a.id === action.condition.selected_action_id, - )[0]?.custom_description || - flow.actions.filter( - (a: any) => - a.id === action.condition.selected_action_id, - )[0]?.description || - "No description available"} -

-
-
- - - Key - Type - Value - Logic - - - {action.condition.condition_items.map( - (condition: any, index: number) => ( - - {condition.condition_key} - - {condition.condition_type} - - - {condition.condition_value} - - - {condition.condition_logic === "and" - ? "&" - : "or"} - - - ), - )} - -
-
- )} -
-
+ + + + + + +
- +
); @@ -684,6 +426,11 @@ export default function Actions({ runners={runners} user={user} /> + { if (flow.failure_pipelines !== null) { setFailurePipelines(flow.failure_pipelines); @@ -138,299 +124,204 @@ export default function FlowFailurePipelines({ return (
- + { + setTargetAction(action); + viewFlowActionDetails.onOpen(); + }} + > -
-
-
-
-
- -
-
-
-

- {action.custom_name - ? action.custom_name - : action.name} -

- - Vers. {action.version} - - - {action.active ? "Active" : "Disabled"} - - {flow.failure_pipeline_id !== "" || - (flow.failure_pipeline_id !== null && - !flow.failure_pipelines.some( - (pipeline: any) => - pipeline.id === action.failure_pipeline_id || - (pipeline.actions !== null && - pipeline.actions.some( - (pipelineAction: any) => - pipelineAction.id === action.id, - )), - ) && ( - - No Failure Pipeline Assigned - - ))} -
-

- {action.custom_description - ? action.custom_description - : action.description} -

-
-
-
- - - - - - - - } - onPress={() => { - navigator.clipboard.writeText( - JSON.stringify(action), - ); - addToast({ - title: "Action", - description: "Action copied to clipboard!", - color: "success", - variant: "flat", - }); - }} - > - Clipboard - - - } - onPress={() => { - setTargetAction(action); - setTargetFailurePipeline( - flow.failure_pipelines.filter( - (pipeline: any) => - pipeline.actions !== null && - pipeline.actions.some( - (pipelineAction: any) => - pipelineAction.id === action.id, - ), - )[0], - ); - copyFlowFailurePipelineActionModal.onOpen(); - }} - > - Local - - - } - onPress={() => { - // if action is in an failure pipeline, open the edit modal - if ( - flow.failure_pipelines.some( - (pipeline: any) => - pipeline.actions !== null && - pipeline.actions.some( - (pipelineAction: any) => - pipelineAction.id === action.id, - ), - ) - ) { - setTargetAction(action); - setTargetFailurePipeline( - flow.failure_pipelines.filter( - (pipeline: any) => - pipeline.actions !== null && - pipeline.actions.some( - (pipelineAction: any) => - pipelineAction.id === action.id, - ), - )[0], - ); - copyFailurePipelineActionToDifferentFlowModal.onOpen(); - } else { - setTargetAction(action); - copyActionToDifferentFlowModal.onOpen(); - } - }} - > - Transfer - - - - - - - - - -
+
+
+
+
- -
- +

+ {action.custom_name ? action.custom_name : action.name} +

+

+ {action.custom_description + ? action.custom_description + : action.description} +

+
+
+
+ + + +
+
+ + +
+ + Vers. {action.version} + + + {action.active ? "Active" : "Disabled"} + + {flow.failure_pipeline_id !== "" || + (flow.failure_pipeline_id !== null && + !flow.failure_pipelines.some( + (pipeline: any) => + pipeline.id === action.failure_pipeline_id || + (pipeline.actions !== null && + pipeline.actions.some( + (pipelineAction: any) => + pipelineAction.id === action.id, + )), + ) && ( + + No Failure Pipeline Assigned + + ))} + {action.update_available && ( + + Upgrade Available + + )} +
+
+ + {action.update_available && ( + + + + )} + +
- {action.update_available && ( - - } - variant="flat" - onPress={() => { + + + + + + + + + + } + onPress={() => { + navigator.clipboard.writeText(JSON.stringify(action)); + addToast({ + title: "Action", + description: "Action copied to clipboard!", + color: "success", + variant: "flat", + }); + }} + > + Clipboard + + + } + onPress={() => { + setTargetAction(action); + setTargetFailurePipeline( + flow.failure_pipelines.filter( + (pipeline: any) => + pipeline.actions !== null && + pipeline.actions.some( + (pipelineAction: any) => + pipelineAction.id === action.id, + ), + )[0], + ); + copyFlowFailurePipelineActionModal.onOpen(); + }} + > + Local + + + } + onPress={() => { + // if action is in an failure pipeline, open the edit modal + if ( + flow.failure_pipelines.some( + (pipeline: any) => + pipeline.actions !== null && + pipeline.actions.some( + (pipelineAction: any) => + pipelineAction.id === action.id, + ), + ) + ) { setTargetAction(action); - setUpdatedAction(action.updated_action); setTargetFailurePipeline( flow.failure_pipelines.filter( (pipeline: any) => @@ -441,235 +332,71 @@ export default function FlowFailurePipelines({ ), )[0], ); - upgradeFlowFailurePipelineActionModal.onOpen(); - }} - > - Upgrade - + copyFailurePipelineActionToDifferentFlowModal.onOpen(); + } else { + setTargetAction(action); + copyActionToDifferentFlowModal.onOpen(); + } + }} + > + Transfer + + + + +
+ )[0], + ); + deleteFlowFailurePipelineActionModal.onOpen(); + }} + > + + + +
- +
); @@ -937,6 +664,11 @@ export default function FlowFailurePipelines({ runners={runners} user={user} /> +