From 145ebf30ea73d5e6fe69dbbd9ca88e708bc90f4e Mon Sep 17 00:00:00 2001 From: rxdn <29165304+rxdn@users.noreply.github.com> Date: Sun, 10 Jul 2022 13:19:01 +0100 Subject: [PATCH] Integrations --- .../api/integrations/activateintegration.go | 96 ++++ .../api/integrations/createintegration.go | 130 +++++ .../api/integrations/deleteintegration.go | 42 ++ .../endpoints/api/integrations/editsecrets.go | 79 +++ .../api/integrations/getintegration.go | 101 ++++ .../integrations/getintegrationdetailed.go | 86 +++ .../endpoints/api/integrations/isactive.go | 28 + .../api/integrations/listintegrations.go | 78 +++ .../api/integrations/ownedintegrations.go | 74 +++ .../api/integrations/removeintegration.go | 25 + .../endpoints/api/integrations/setpublic.go | 76 +++ .../api/integrations/updateintegration.go | 268 ++++++++++ app/http/server.go | 17 + botcontext/get.go | 29 +- config/config.go | 44 +- database/database.go | 3 +- frontend/public/assets/img/grey.png | Bin 0 -> 26735 bytes frontend/public/global.css | 5 +- frontend/src/components/Badge.svelte | 10 +- frontend/src/components/Button.svelte | 16 +- .../src/components/ConfirmationModal.svelte | 73 +++ .../src/components/IntegrationEditor.svelte | 501 ++++++++++++++++++ frontend/src/components/NavElement.svelte | 7 + .../src/components/manage/Integration.svelte | 141 +++++ frontend/src/includes/Navbar.svelte | 25 +- frontend/src/routes.js | 41 ++ .../src/views/integrations/Activate.svelte | 160 ++++++ .../src/views/integrations/Configure.svelte | 71 +++ frontend/src/views/integrations/Create.svelte | 27 + .../views/integrations/Integrations.svelte | 249 +++++++++ frontend/src/views/integrations/Manage.svelte | 174 ++++++ frontend/src/views/integrations/View.svelte | 174 ++++++ go.mod | 9 +- go.sum | 10 +- utils/imageproxy.go | 19 + utils/netutils.go | 12 + utils/utils.go | 4 + 37 files changed, 2846 insertions(+), 58 deletions(-) create mode 100644 app/http/endpoints/api/integrations/activateintegration.go create mode 100644 app/http/endpoints/api/integrations/createintegration.go create mode 100644 app/http/endpoints/api/integrations/deleteintegration.go create mode 100644 app/http/endpoints/api/integrations/editsecrets.go create mode 100644 app/http/endpoints/api/integrations/getintegration.go create mode 100644 app/http/endpoints/api/integrations/getintegrationdetailed.go create mode 100644 app/http/endpoints/api/integrations/isactive.go create mode 100644 app/http/endpoints/api/integrations/listintegrations.go create mode 100644 app/http/endpoints/api/integrations/ownedintegrations.go create mode 100644 app/http/endpoints/api/integrations/removeintegration.go create mode 100644 app/http/endpoints/api/integrations/setpublic.go create mode 100644 app/http/endpoints/api/integrations/updateintegration.go create mode 100644 frontend/public/assets/img/grey.png create mode 100644 frontend/src/components/ConfirmationModal.svelte create mode 100644 frontend/src/components/IntegrationEditor.svelte create mode 100644 frontend/src/components/manage/Integration.svelte create mode 100644 frontend/src/views/integrations/Activate.svelte create mode 100644 frontend/src/views/integrations/Configure.svelte create mode 100644 frontend/src/views/integrations/Create.svelte create mode 100644 frontend/src/views/integrations/Integrations.svelte create mode 100644 frontend/src/views/integrations/Manage.svelte create mode 100644 frontend/src/views/integrations/View.svelte create mode 100644 utils/imageproxy.go create mode 100644 utils/netutils.go diff --git a/app/http/endpoints/api/integrations/activateintegration.go b/app/http/endpoints/api/integrations/activateintegration.go new file mode 100644 index 0000000..ea85f59 --- /dev/null +++ b/app/http/endpoints/api/integrations/activateintegration.go @@ -0,0 +1,96 @@ +package api + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/gin-gonic/gin" + "strconv" +) + +type activateIntegrationBody struct { + Secrets map[string]string `json:"secrets"` +} + +func ActivateIntegrationHandler(ctx *gin.Context) { + userId := ctx.Keys["userid"].(uint64) + guildId := ctx.Keys["guildid"].(uint64) + + activeCount, err := dbclient.Client.CustomIntegrationGuilds.GetGuildIntegrationCount(guildId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if activeCount >= 5 { + ctx.JSON(400, utils.ErrorStr("You can only have 5 integrations active at once")) + return + } + + integrationId, err := strconv.Atoi(ctx.Param("integrationid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid integration ID")) + return + } + + var data activateIntegrationBody + if err := ctx.BindJSON(&data); err != nil { + ctx.JSON(400, utils.ErrorJson(err)) + return + } + + // Check the integration is public or the user created it + canActivate, err := dbclient.Client.CustomIntegrationGuilds.CanActivate(integrationId, userId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !canActivate { + ctx.JSON(403, utils.ErrorStr("You do not have permission to activate this integration")) + return + } + + // Check the secret values are valid + secrets, err := dbclient.Client.CustomIntegrationSecrets.GetByIntegration(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if len(secrets) != len(data.Secrets) { + ctx.JSON(400, utils.ErrorStr("Invalid secret values")) + return + } + + // Since we've checked the length, we can just iterate over the secrets and they're guaranteed to be correct + secretMap := make(map[int]string) + for secretName, value := range data.Secrets { + if len(value) == 0 || len(value) > 255 { + ctx.JSON(400, utils.ErrorStr("Secret values must be between 1 and 255 characters")) + return + } + + found := false + + inner: + for _, secret := range secrets { + if secret.Name == secretName { + found = true + secretMap[secret.Id] = value + break inner + } + } + + if !found { + ctx.JSON(400, utils.ErrorStr("Invalid secret values")) + return + } + } + + if err := dbclient.Client.CustomIntegrationGuilds.AddToGuildWithSecrets(integrationId, guildId, secretMap); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + ctx.Status(204) +} diff --git a/app/http/endpoints/api/integrations/createintegration.go b/app/http/endpoints/api/integrations/createintegration.go new file mode 100644 index 0000000..f75094b --- /dev/null +++ b/app/http/endpoints/api/integrations/createintegration.go @@ -0,0 +1,130 @@ +package api + +import ( + "fmt" + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/TicketsBot/database" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "strings" +) + +type integrationCreateBody struct { + Name string `json:"name" validate:"required,min=1,max=32"` + Description string `json:"description" validate:"required,min=1,max=255"` + ImageUrl *string `json:"image_url" validate:"omitempty,url,max=255,startswith=https://"` + PrivacyPolicyUrl *string `json:"privacy_policy_url" validate:"omitempty,url,max=255,startswith=https://"` + + Method string `json:"http_method" validate:"required,oneof=GET POST"` + WebhookUrl string `json:"webhook_url" validate:"required,url,max=255,startswith=https://"` + + Secrets []struct { + Name string `json:"name" validate:"required,min=1,max=32,excludesall=% "` + } `json:"secrets" validate:"dive,omitempty,min=0,max=5"` + + Headers []struct { + Name string `json:"name" validate:"required,min=1,max=32,excludes= "` + Value string `json:"value" validate:"required,min=1,max=255"` + } `json:"headers" validate:"dive,omitempty,min=0,max=5"` + + Placeholders []struct { + Placeholder string `json:"name" validate:"required,min=1,max=32,excludesall=% "` + JsonPath string `json:"json_path" validate:"required,min=1,max=255"` + } `json:"placeholders" validate:"dive,omitempty,min=0,max=15"` +} + +var validate = validator.New() + +func CreateIntegrationHandler(ctx *gin.Context) { + userId := ctx.Keys["userid"].(uint64) + + ownedCount, err := dbclient.Client.CustomIntegrations.GetOwnedCount(userId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if ownedCount >= 5 { + ctx.JSON(403, utils.ErrorStr("You have reached the integration limit (5/5)")) + return + } + + var data integrationCreateBody + if err := ctx.BindJSON(&data); err != nil { + ctx.JSON(400, utils.ErrorJson(err)) + return + } + + if err := validate.Struct(data); err != nil { + validationErrors, ok := err.(validator.ValidationErrors) + if !ok { + ctx.JSON(500, utils.ErrorStr("An error occurred while validating the integration")) + return + } + + formatted := "Your input contained the following errors:" + for _, validationError := range validationErrors { + formatted += fmt.Sprintf("\n%s", validationError.Error()) + } + + formatted = strings.TrimSuffix(formatted, "\n") + ctx.JSON(400, utils.ErrorStr(formatted)) + return + } + + integration, err := dbclient.Client.CustomIntegrations.Create(userId, data.WebhookUrl, data.Method, data.Name, data.Description, data.ImageUrl, data.PrivacyPolicyUrl) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + // Store secrets + if len(data.Secrets) > 0 { + secrets := make([]database.CustomIntegrationSecret, len(data.Secrets)) + for i, secret := range data.Secrets { + secrets[i] = database.CustomIntegrationSecret{ + Name: secret.Name, + } + } + + if _, err := dbclient.Client.CustomIntegrationSecrets.CreateOrUpdate(integration.Id, secrets); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + } + + // Store headers + if len(data.Headers) > 0 { + headers := make([]database.CustomIntegrationHeader, len(data.Headers)) + for i, header := range data.Headers { + headers[i] = database.CustomIntegrationHeader{ + Name: header.Name, + Value: header.Value, + } + } + + if _, err := dbclient.Client.CustomIntegrationHeaders.CreateOrUpdate(integration.Id, headers); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + } + + // Store placeholders + if len(data.Placeholders) > 0 { + placeholders := make([]database.CustomIntegrationPlaceholder, len(data.Placeholders)) + for i, placeholder := range data.Placeholders { + placeholders[i] = database.CustomIntegrationPlaceholder{ + Name: placeholder.Placeholder, + JsonPath: placeholder.JsonPath, + } + } + + if _, err := dbclient.Client.CustomIntegrationPlaceholders.Set(integration.Id, placeholders); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + } + + ctx.JSON(200, integration) +} diff --git a/app/http/endpoints/api/integrations/deleteintegration.go b/app/http/endpoints/api/integrations/deleteintegration.go new file mode 100644 index 0000000..17f8337 --- /dev/null +++ b/app/http/endpoints/api/integrations/deleteintegration.go @@ -0,0 +1,42 @@ +package api + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/gin-gonic/gin" + "strconv" +) + +func DeleteIntegrationHandler(ctx *gin.Context) { + userId := ctx.Keys["userid"].(uint64) + + integrationId, err := strconv.Atoi(ctx.Param("integrationid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid integration ID")) + return + } + + integration, ok, err := dbclient.Client.CustomIntegrations.Get(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !ok { + ctx.JSON(404, utils.ErrorStr("Integration not found")) + return + } + + // Check if the user has permission to manage this integration + if integration.OwnerId != userId { + ctx.JSON(403, utils.ErrorStr("You do not have permission to delete this integration")) + return + } + + if err := dbclient.Client.CustomIntegrations.Delete(integration.Id); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + ctx.Status(204) +} diff --git a/app/http/endpoints/api/integrations/editsecrets.go b/app/http/endpoints/api/integrations/editsecrets.go new file mode 100644 index 0000000..e23610e --- /dev/null +++ b/app/http/endpoints/api/integrations/editsecrets.go @@ -0,0 +1,79 @@ +package api + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/gin-gonic/gin" + "strconv" +) + +func UpdateIntegrationSecretsHandler(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + + integrationId, err := strconv.Atoi(ctx.Param("integrationid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid integration ID")) + return + } + + // Check integration is active + active, err := dbclient.Client.CustomIntegrationGuilds.IsActive(integrationId, guildId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !active { + ctx.JSON(400, utils.ErrorStr("Integration is not active")) + return + } + + var data activateIntegrationBody + if err := ctx.BindJSON(&data); err != nil { + ctx.JSON(400, utils.ErrorJson(err)) + return + } + // Check the secret values are valid + secrets, err := dbclient.Client.CustomIntegrationSecrets.GetByIntegration(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if len(secrets) != len(data.Secrets) { + ctx.JSON(400, utils.ErrorStr("Invalid secret values")) + return + } + + // Since we've checked the length, we can just iterate over the secrets and they're guaranteed to be correct + secretMap := make(map[int]string) + for secretName, value := range data.Secrets { + if len(value) == 0 || len(value) > 255 { + ctx.JSON(400, utils.ErrorStr("Secret values must be between 1 and 255 characters")) + return + } + + found := false + + inner: + for _, secret := range secrets { + if secret.Name == secretName { + found = true + secretMap[secret.Id] = value + break inner + } + } + + if !found { + ctx.JSON(400, utils.ErrorStr("Invalid secret values")) + return + } + } + + if err := dbclient.Client.CustomIntegrationSecretValues.UpdateAll(guildId, integrationId, secretMap); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + ctx.Status(204) +} diff --git a/app/http/endpoints/api/integrations/getintegration.go b/app/http/endpoints/api/integrations/getintegration.go new file mode 100644 index 0000000..ce0258c --- /dev/null +++ b/app/http/endpoints/api/integrations/getintegration.go @@ -0,0 +1,101 @@ +package api + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/TicketsBot/database" + "github.com/gin-gonic/gin" + "strconv" +) + +type integrationResponse struct { + // Strip out the sensitive fields + Id int `json:"id"` + OwnerId uint64 `json:"owner_id"` + WebhookHost string `json:"webhook_url"` + Name string `json:"name"` + Description string `json:"description"` + ImageUrl *string `json:"image_url"` + ProxyToken *string `json:"proxy_token,omitempty"` + PrivacyPolicyUrl *string `json:"privacy_policy_url"` + Public bool `json:"public"` + Approved bool `json:"approved"` + + Placeholders []database.CustomIntegrationPlaceholder `json:"placeholders"` + Secrets []database.CustomIntegrationSecret `json:"secrets"` +} + +func GetIntegrationHandler(ctx *gin.Context) { + userId := ctx.Keys["userid"].(uint64) + + integrationId, err := strconv.Atoi(ctx.Param("integrationid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid integration ID")) + return + } + + integration, ok, err := dbclient.Client.CustomIntegrations.Get(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !ok { + ctx.JSON(404, utils.ErrorStr("Integration not found")) + return + } + + // Check if the user has permission to view this integration + if integration.OwnerId != userId && !(integration.Public && integration.Approved) { + ctx.JSON(403, utils.ErrorStr("You do not have permission to view this integration")) + return + } + + placeholders, err := dbclient.Client.CustomIntegrationPlaceholders.GetByIntegration(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + // Don't serve null + if placeholders == nil { + placeholders = make([]database.CustomIntegrationPlaceholder, 0) + } + + secrets, err := dbclient.Client.CustomIntegrationSecrets.GetByIntegration(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + // Don't serve null + if secrets == nil { + secrets = make([]database.CustomIntegrationSecret, 0) + } + + var proxyToken *string + if integration.ImageUrl != nil { + tmp, err := utils.GenerateImageProxyToken(*integration.ImageUrl) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + proxyToken = &tmp + } + + ctx.JSON(200, integrationResponse{ + Id: integration.Id, + OwnerId: integration.OwnerId, + WebhookHost: utils.GetUrlHost(integration.WebhookUrl), + Name: integration.Name, + Description: integration.Description, + ImageUrl: integration.ImageUrl, + ProxyToken: proxyToken, + PrivacyPolicyUrl: integration.PrivacyPolicyUrl, + Public: integration.Public, + Approved: integration.Approved, + Placeholders: placeholders, + Secrets: secrets, + }) +} diff --git a/app/http/endpoints/api/integrations/getintegrationdetailed.go b/app/http/endpoints/api/integrations/getintegrationdetailed.go new file mode 100644 index 0000000..632eee6 --- /dev/null +++ b/app/http/endpoints/api/integrations/getintegrationdetailed.go @@ -0,0 +1,86 @@ +package api + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/TicketsBot/database" + "github.com/gin-gonic/gin" + "strconv" +) + +type detailedResponse struct { + database.CustomIntegration + Placeholders []database.CustomIntegrationPlaceholder `json:"placeholders"` + Headers []database.CustomIntegrationHeader `json:"headers"` + Secrets []database.CustomIntegrationSecret `json:"secrets"` +} + +func GetIntegrationDetailedHandler(ctx *gin.Context) { + userId := ctx.Keys["userid"].(uint64) + + integrationId, err := strconv.Atoi(ctx.Param("integrationid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid integration ID")) + return + } + + integration, ok, err := dbclient.Client.CustomIntegrations.Get(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !ok { + ctx.JSON(404, utils.ErrorStr("Integration not found")) + return + } + + // Check if the user has permission to view this integration + if integration.OwnerId != userId { + ctx.JSON(403, utils.ErrorStr("You do not have permission to view this integration")) + return + } + + // Get placeholders + placeholders, err := dbclient.Client.CustomIntegrationPlaceholders.GetByIntegration(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + // Don't serve null + if placeholders == nil { + placeholders = make([]database.CustomIntegrationPlaceholder, 0) + } + + // Get headers + headers, err := dbclient.Client.CustomIntegrationHeaders.GetByIntegration(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + // Don't serve null + if headers == nil { + headers = make([]database.CustomIntegrationHeader, 0) + } + + // Get secrets + secrets, err := dbclient.Client.CustomIntegrationSecrets.GetByIntegration(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + // Don't serve null + if secrets == nil { + secrets = make([]database.CustomIntegrationSecret, 0) + } + + ctx.JSON(200, detailedResponse{ + CustomIntegration: integration, + Placeholders: placeholders, + Headers: headers, + Secrets: secrets, + }) +} diff --git a/app/http/endpoints/api/integrations/isactive.go b/app/http/endpoints/api/integrations/isactive.go new file mode 100644 index 0000000..f21b2ae --- /dev/null +++ b/app/http/endpoints/api/integrations/isactive.go @@ -0,0 +1,28 @@ +package api + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/gin-gonic/gin" + "strconv" +) + +func IsIntegrationActiveHandler(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + + integrationId, err := strconv.Atoi(ctx.Param("integrationid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid integration ID")) + return + } + + active, err := dbclient.Client.CustomIntegrationGuilds.IsActive(integrationId, guildId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + ctx.JSON(200, gin.H{ + "active": active, + }) +} diff --git a/app/http/endpoints/api/integrations/listintegrations.go b/app/http/endpoints/api/integrations/listintegrations.go new file mode 100644 index 0000000..ab05808 --- /dev/null +++ b/app/http/endpoints/api/integrations/listintegrations.go @@ -0,0 +1,78 @@ +package api + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/gin-gonic/gin" + "strconv" +) + +const pageLimit = 20 +const builtInCount = 1 + +type integrationWithMetadata struct { + integrationResponse + GuildCount int `json:"guild_count"` + Added bool `json:"added"` +} + +func ListIntegrationsHandler(ctx *gin.Context) { + userId := ctx.Keys["userid"].(uint64) + guildId := ctx.Keys["guildid"].(uint64) + + page, err := strconv.Atoi(ctx.Query("page")) + if err != nil || page <= 1 { + page = 1 + } + + page -= 1 + + limit := pageLimit + if page == 0 { + limit -= builtInCount + } + + availableIntegrations, err := dbclient.Client.CustomIntegrationGuilds.GetAvailableIntegrationsWithActive(guildId, userId, limit, page*pageLimit) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + integrations := make([]integrationWithMetadata, len(availableIntegrations)) + for i, integration := range availableIntegrations { + var proxyToken *string + if integration.ImageUrl != nil { + tmp, err := utils.GenerateImageProxyToken(*integration.ImageUrl) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + proxyToken = &tmp + } + + integrations[i] = integrationWithMetadata{ + integrationResponse: integrationResponse{ + Id: integration.Id, + OwnerId: integration.OwnerId, + WebhookHost: utils.GetUrlHost(integration.WebhookUrl), + Name: integration.Name, + Description: integration.Description, + ImageUrl: integration.ImageUrl, + ProxyToken: proxyToken, + PrivacyPolicyUrl: integration.PrivacyPolicyUrl, + Public: integration.Public, + Approved: integration.Approved, + }, + GuildCount: integration.GuildCount, + Added: integration.Active, + } + } + + // Don't serve null + if integrations == nil { + integrations = make([]integrationWithMetadata, 0) + } + + ctx.JSON(200, integrations) +} diff --git a/app/http/endpoints/api/integrations/ownedintegrations.go b/app/http/endpoints/api/integrations/ownedintegrations.go new file mode 100644 index 0000000..e413416 --- /dev/null +++ b/app/http/endpoints/api/integrations/ownedintegrations.go @@ -0,0 +1,74 @@ +package api + +import ( + "context" + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/TicketsBot/database" + "github.com/gin-gonic/gin" + "golang.org/x/sync/errgroup" +) + +type integrationWithCount struct { + integrationResponse + GuildCount int `json:"guild_count"` +} + +func GetOwnedIntegrationsHandler(ctx *gin.Context) { + userId := ctx.Keys["userid"].(uint64) + + group, _ := errgroup.WithContext(context.Background()) + + var integrations []database.CustomIntegrationWithGuildCount + var placeholders map[int][]database.CustomIntegrationPlaceholder + + // Retrieve integrations + group.Go(func() (err error) { + integrations, err = dbclient.Client.CustomIntegrations.GetAllOwned(userId) + return + }) + + // Retrieve placeholders + group.Go(func() (err error) { + placeholders, err = dbclient.Client.CustomIntegrationPlaceholders.GetAllForOwnedIntegrations(userId) + return + }) + + if err := group.Wait(); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + res := make([]integrationWithCount, len(integrations)) + for i, integration := range integrations { + var proxyToken *string + if integration.ImageUrl != nil { + tmp, err := utils.GenerateImageProxyToken(*integration.ImageUrl) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + proxyToken = &tmp + } + + res[i] = integrationWithCount{ + integrationResponse: integrationResponse{ + Id: integration.Id, + OwnerId: integration.OwnerId, + WebhookHost: utils.GetUrlHost(integration.WebhookUrl), + Name: integration.Name, + Description: integration.Description, + ImageUrl: integration.ImageUrl, + ProxyToken: proxyToken, + PrivacyPolicyUrl: integration.PrivacyPolicyUrl, + Public: integration.Public, + Approved: integration.Approved, + Placeholders: placeholders[integration.Id], + }, + GuildCount: integration.GuildCount, + } + } + + ctx.JSON(200, res) +} diff --git a/app/http/endpoints/api/integrations/removeintegration.go b/app/http/endpoints/api/integrations/removeintegration.go new file mode 100644 index 0000000..36e8d28 --- /dev/null +++ b/app/http/endpoints/api/integrations/removeintegration.go @@ -0,0 +1,25 @@ +package api + +import ( + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/gin-gonic/gin" + "strconv" +) + +func RemoveIntegrationHandler(ctx *gin.Context) { + guildId := ctx.Keys["guildid"].(uint64) + + integrationId, err := strconv.Atoi(ctx.Param("integrationid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid integration ID")) + return + } + + if err := dbclient.Client.CustomIntegrationGuilds.RemoveFromGuild(integrationId, guildId); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + ctx.Status(204) +} diff --git a/app/http/endpoints/api/integrations/setpublic.go b/app/http/endpoints/api/integrations/setpublic.go new file mode 100644 index 0000000..c5fc1c0 --- /dev/null +++ b/app/http/endpoints/api/integrations/setpublic.go @@ -0,0 +1,76 @@ +package api + +import ( + "fmt" + "github.com/TicketsBot/GoPanel/botcontext" + "github.com/TicketsBot/GoPanel/config" + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/gin-gonic/gin" + "github.com/rxdn/gdl/objects/channel/embed" + "github.com/rxdn/gdl/rest" + "strconv" +) + +func SetIntegrationPublicHandler(ctx *gin.Context) { + userId := ctx.Keys["userid"].(uint64) + + integrationId, err := strconv.Atoi(ctx.Param("integrationid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid integration ID")) + return + } + + integration, ok, err := dbclient.Client.CustomIntegrations.Get(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !ok { + ctx.JSON(404, utils.ErrorStr("Integration not found")) + return + } + + if integration.OwnerId != userId { + ctx.JSON(403, utils.ErrorStr("You do not have permission to manage this integration")) + return + } + + if integration.Public { + ctx.JSON(400, utils.ErrorStr("You have already requested to make this integration public")) + return + } + + e := embed.NewEmbed(). + SetTitle("Public Integration Request"). + SetColor(0xfcb97d). + AddField("Integration ID", strconv.Itoa(integration.Id), true). + AddField("Integration Name", integration.Name, true). + AddField("Integration URL", fmt.Sprintf("`%s`", integration.WebhookUrl), true). + AddField("Integration Owner", fmt.Sprintf("<@%d>", integration.OwnerId), true). + AddField("Integration Description", integration.Description, false) + + botCtx := botcontext.PublicContext() + _, err = rest.ExecuteWebhook( + config.Conf.Bot.PublicIntegrationRequestWebhookToken, + botCtx.RateLimiter, + config.Conf.Bot.PublicIntegrationRequestWebhookId, + true, + rest.WebhookBody{ + Embeds: utils.Slice(e), + }, + ) + + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if err := dbclient.Client.CustomIntegrations.SetPublic(integration.Id); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + ctx.Status(204) +} diff --git a/app/http/endpoints/api/integrations/updateintegration.go b/app/http/endpoints/api/integrations/updateintegration.go new file mode 100644 index 0000000..b577d7e --- /dev/null +++ b/app/http/endpoints/api/integrations/updateintegration.go @@ -0,0 +1,268 @@ +package api + +import ( + "fmt" + dbclient "github.com/TicketsBot/GoPanel/database" + "github.com/TicketsBot/GoPanel/utils" + "github.com/TicketsBot/database" + "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" + "strconv" + "strings" +) + +type integrationUpdateBody struct { + Name string `json:"name" validate:"required,min=1,max=32"` + Description string `json:"description" validate:"required,min=1,max=255"` + ImageUrl *string `json:"image_url" validate:"omitempty,url,max=255,startswith=https://"` + PrivacyPolicyUrl *string `json:"privacy_policy_url" validate:"omitempty,url,max=255,startswith=https://"` + + Method string `json:"http_method" validate:"required,oneof=GET POST"` + WebhookUrl string `json:"webhook_url" validate:"required,url,max=255,startswith=https://"` + + Secrets []struct { + Id int `json:"id" validate:"omitempty,min=1"` + Name string `json:"name" validate:"required,min=1,max=32,excludesall=% "` + } `json:"secrets" validate:"dive,omitempty,min=0,max=5"` + + Headers []struct { + Id int `json:"id" validate:"omitempty,min=1"` + Name string `json:"name" validate:"required,min=1,max=32,excludes= "` + Value string `json:"value" validate:"required,min=1,max=255"` + } `json:"headers" validate:"dive,omitempty,min=0,max=5"` + + Placeholders []struct { + Id int `json:"id" validate:"omitempty,min=1"` + Placeholder string `json:"name" validate:"required,min=1,max=32,excludesall=% "` + JsonPath string `json:"json_path" validate:"required,min=1,max=255"` + } `json:"placeholders" validate:"dive,omitempty,min=0,max=15"` +} + +func UpdateIntegrationHandler(ctx *gin.Context) { + userId := ctx.Keys["userid"].(uint64) + + integrationId, err := strconv.Atoi(ctx.Param("integrationid")) + if err != nil { + ctx.JSON(400, utils.ErrorStr("Invalid integration ID")) + return + } + + var data integrationUpdateBody + if err := ctx.BindJSON(&data); err != nil { + ctx.JSON(400, utils.ErrorJson(err)) + return + } + + if err := validate.Struct(data); err != nil { + validationErrors, ok := err.(validator.ValidationErrors) + if !ok { + ctx.JSON(500, utils.ErrorStr("An error occurred while validating the integration")) + return + } + + formatted := "Your input contained the following errors:" + for _, validationError := range validationErrors { + formatted += fmt.Sprintf("\n%s", validationError.Error()) + } + + formatted = strings.TrimSuffix(formatted, "\n") + ctx.JSON(400, utils.ErrorStr(formatted)) + return + } + + integration, ok, err := dbclient.Client.CustomIntegrations.Get(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + if !ok { + ctx.JSON(404, utils.ErrorStr("Integration not found")) + return + } + + if integration.OwnerId != userId { + ctx.JSON(403, utils.ErrorStr("You do not own this integration")) + return + } + + // Update integration metadata + err = dbclient.Client.CustomIntegrations.Update(database.CustomIntegration{ + Id: integration.Id, + OwnerId: integration.OwnerId, + HttpMethod: data.Method, + WebhookUrl: data.WebhookUrl, + Name: data.Name, + Description: data.Description, + ImageUrl: data.ImageUrl, + PrivacyPolicyUrl: data.PrivacyPolicyUrl, + Public: integration.Public, + Approved: integration.Approved, + }) + + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return + } + + // Store secrets + if !data.updateSecrets(ctx, integration.Id) { + return + } + + // Store headers + if !data.updateHeaders(ctx, integration.Id) { + return + } + + // Store placeholders + if !data.updatePlaceholders(ctx, integration.Id) { + return + } + + ctx.JSON(200, integration) +} + +func (b *integrationUpdateBody) updatePlaceholders(ctx *gin.Context, integrationId int) bool { + // Verify IDs are valid for the integration + existingPlaceholders, err := dbclient.Client.CustomIntegrationPlaceholders.GetByIntegration(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return false + } + + for _, placeholder := range b.Placeholders { + if placeholder.Id != 0 { + isValid := false + inner: + for _, existingPlaceholder := range existingPlaceholders { + if existingPlaceholder.Id == placeholder.Id { + if existingPlaceholder.IntegrationId == integrationId { + isValid = true + break inner + } else { + ctx.JSON(400, utils.ErrorStr("Integration ID mismatch for placeholders")) + return false + } + } + } + + if !isValid { + ctx.JSON(400, utils.ErrorStr("Integration ID mismatch for placeholders")) + return false + } + } + } + + placeholders := make([]database.CustomIntegrationPlaceholder, len(b.Placeholders)) + for i, placeholder := range b.Placeholders { + placeholders[i] = database.CustomIntegrationPlaceholder{ + Id: placeholder.Id, + Name: placeholder.Placeholder, + JsonPath: placeholder.JsonPath, + } + } + + if _, err := dbclient.Client.CustomIntegrationPlaceholders.Set(integrationId, placeholders); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return false + } + + return true +} + +func (b *integrationUpdateBody) updateHeaders(ctx *gin.Context, integrationId int) bool { + // Verify IDs are valid for the integration + existingHeaders, err := dbclient.Client.CustomIntegrationHeaders.GetByIntegration(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return false + } + + for _, header := range b.Headers { + if header.Id != 0 { + isValid := false + + inner: + for _, existingHeader := range existingHeaders { + if existingHeader.Id == header.Id { + if existingHeader.IntegrationId == integrationId { + isValid = true + break inner + } else { + ctx.JSON(400, utils.ErrorStr("Integration ID mismatch for headers")) + return false + } + } + } + + if !isValid { + ctx.JSON(400, utils.ErrorStr("Integration ID mismatch for headers")) + return false + } + } + } + + headers := make([]database.CustomIntegrationHeader, len(b.Headers)) + for i, header := range b.Headers { + headers[i] = database.CustomIntegrationHeader{ + Id: header.Id, + Name: header.Name, + Value: header.Value, + } + } + + if _, err := dbclient.Client.CustomIntegrationHeaders.CreateOrUpdate(integrationId, headers); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return false + } + + return true +} + +func (b *integrationUpdateBody) updateSecrets(ctx *gin.Context, integrationId int) bool { + // Verify IDs are valid for the integration + existingSecrets, err := dbclient.Client.CustomIntegrationSecrets.GetByIntegration(integrationId) + if err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return false + } + + for _, secret := range b.Secrets { + if secret.Id != 0 { + isValid := false + inner: + for _, existingSecret := range existingSecrets { + if existingSecret.Id == secret.Id { + if existingSecret.IntegrationId == integrationId { + isValid = true + break inner + } else { + ctx.JSON(400, utils.ErrorStr("Integration ID mismatch for secrets")) + return false + } + } + } + + if !isValid { + ctx.JSON(400, utils.ErrorStr("Integration ID mismatch for secrets")) + return false + } + } + } + + secrets := make([]database.CustomIntegrationSecret, len(b.Secrets)) + for i, secret := range b.Secrets { + secrets[i] = database.CustomIntegrationSecret{ + Id: secret.Id, + Name: secret.Name, + } + } + + if _, err := dbclient.Client.CustomIntegrationSecrets.CreateOrUpdate(integrationId, secrets); err != nil { + ctx.JSON(500, utils.ErrorJson(err)) + return false + } + + return true +} diff --git a/app/http/server.go b/app/http/server.go index 5d864a4..7daa7f7 100644 --- a/app/http/server.go +++ b/app/http/server.go @@ -7,6 +7,7 @@ import ( api_blacklist "github.com/TicketsBot/GoPanel/app/http/endpoints/api/blacklist" api_customisation "github.com/TicketsBot/GoPanel/app/http/endpoints/api/customisation" api_forms "github.com/TicketsBot/GoPanel/app/http/endpoints/api/forms" + api_integrations "github.com/TicketsBot/GoPanel/app/http/endpoints/api/integrations" api_panels "github.com/TicketsBot/GoPanel/app/http/endpoints/api/panel" api_settings "github.com/TicketsBot/GoPanel/app/http/endpoints/api/settings" api_override "github.com/TicketsBot/GoPanel/app/http/endpoints/api/staffoverride" @@ -67,6 +68,16 @@ func StartServer() { apiGroup := router.Group("/api", middleware.VerifyXTicketsHeader, middleware.AuthenticateToken) { apiGroup.GET("/session", api.SessionHandler) + + integrationGroup := apiGroup.Group("/integrations") + + integrationGroup.GET("/self", api_integrations.GetOwnedIntegrationsHandler) + integrationGroup.GET("/view/:integrationid", api_integrations.GetIntegrationHandler) + integrationGroup.GET("/view/:integrationid/detail", api_integrations.GetIntegrationDetailedHandler) + integrationGroup.POST("/:integrationid/public", api_integrations.SetIntegrationPublicHandler) + integrationGroup.PATCH("/:integrationid", api_integrations.UpdateIntegrationHandler) + integrationGroup.DELETE("/:integrationid", api_integrations.DeleteIntegrationHandler) + apiGroup.POST("/integrations", api_integrations.CreateIntegrationHandler) } guildAuthApiAdmin := apiGroup.Group("/:id", middleware.AuthenticateGuild(permission.Admin)) @@ -156,6 +167,12 @@ func StartServer() { guildAuthApiAdmin.GET("/staff-override", api_override.GetOverrideHandler) guildAuthApiAdmin.POST("/staff-override", api_override.CreateOverrideHandler) guildAuthApiAdmin.DELETE("/staff-override", api_override.DeleteOverrideHandler) + + guildAuthApiAdmin.GET("/integrations/available", api_integrations.ListIntegrationsHandler) + guildAuthApiAdmin.GET("/integrations/:integrationid", api_integrations.IsIntegrationActiveHandler) + guildAuthApiAdmin.POST("/integrations/:integrationid", api_integrations.ActivateIntegrationHandler) + guildAuthApiAdmin.PATCH("/integrations/:integrationid", api_integrations.UpdateIntegrationSecretsHandler) + guildAuthApiAdmin.DELETE("/integrations/:integrationid", api_integrations.RemoveIntegrationHandler) } userGroup := router.Group("/user", middleware.AuthenticateToken) diff --git a/botcontext/get.go b/botcontext/get.go index 45f2382..33091a2 100644 --- a/botcontext/get.go +++ b/botcontext/get.go @@ -14,25 +14,28 @@ func ContextForGuild(guildId uint64) (ctx BotContext, err error) { return } - var keyPrefix string - if isWhitelabel { res, err := dbclient.Client.Whitelabel.GetByBotId(whitelabelBotId) if err != nil { return ctx, err } - ctx.BotId = res.BotId - ctx.Token = res.Token - keyPrefix = fmt.Sprintf("ratelimiter:%d", whitelabelBotId) + rateLimiter := ratelimit.NewRateLimiter(ratelimit.NewRedisStore(redis.Client.Client, fmt.Sprintf("ratelimiter:%d", whitelabelBotId)), 1) + + return BotContext{ + BotId: res.BotId, + Token: res.Token, + RateLimiter: rateLimiter, + }, nil } else { - ctx.BotId = config.Conf.Bot.Id - ctx.Token = config.Conf.Bot.Token - keyPrefix = "ratelimiter:public" + return PublicContext(), nil + } +} + +func PublicContext() BotContext { + return BotContext{ + BotId: config.Conf.Bot.Id, + Token: config.Conf.Bot.Token, + RateLimiter: ratelimit.NewRateLimiter(ratelimit.NewRedisStore(redis.Client.Client, "ratelimiter:public"), 1), } - - // TODO: Large sharding buckets - ctx.RateLimiter = ratelimit.NewRateLimiter(ratelimit.NewRedisStore(redis.Client.Client, keyPrefix), 1) - - return } diff --git a/config/config.go b/config/config.go index 4ae0f99..e0705fd 100644 --- a/config/config.go +++ b/config/config.go @@ -54,14 +54,17 @@ type ( } Bot struct { - Id uint64 - Token string - PremiumLookupProxyUrl string `toml:"premium-lookup-proxy-url"` - PremiumLookupProxyKey string `toml:"premium-lookup-proxy-key"` - ObjectStore string - AesKey string `toml:"aes-key"` - ProxyUrl string `toml:"discord-proxy-url"` - RenderServiceUrl string `toml:"render-service-url"` + Id uint64 + Token string + PremiumLookupProxyUrl string `toml:"premium-lookup-proxy-url"` + PremiumLookupProxyKey string `toml:"premium-lookup-proxy-key"` + ObjectStore string + AesKey string `toml:"aes-key"` + ProxyUrl string `toml:"discord-proxy-url"` + RenderServiceUrl string `toml:"render-service-url"` + ImageProxySecret string `toml:"image-proxy-secret"` + PublicIntegrationRequestWebhookId uint64 `toml:"public-integration-request-webhook-id"` + PublicIntegrationRequestWebhookToken string `toml:"public-integration-request-webhook-token"` } Redis struct { @@ -74,11 +77,6 @@ type ( Cache struct { Uri string } - - Referral struct { - Show bool - Link string - } ) var ( @@ -126,6 +124,7 @@ func fromEnvvar() { botId, _ := strconv.ParseUint(os.Getenv("BOT_ID"), 10, 64) redisPort, _ := strconv.Atoi(os.Getenv("REDIS_PORT")) redisThreads, _ := strconv.Atoi(os.Getenv("REDIS_THREADS")) + publicIntegrationRequestWebhookId, _ := strconv.ParseUint(os.Getenv("PUBLIC_INTEGRATION_REQUEST_WEBHOOK_ID"), 10, 64) Conf = Config{ Admins: admins, @@ -157,14 +156,17 @@ func fromEnvvar() { Uri: os.Getenv("DATABASE_URI"), }, Bot: Bot{ - Id: botId, - Token: os.Getenv("BOT_TOKEN"), - PremiumLookupProxyUrl: os.Getenv("PREMIUM_PROXY_URL"), - PremiumLookupProxyKey: os.Getenv("PREMIUM_PROXY_KEY"), - ObjectStore: os.Getenv("LOG_ARCHIVER_URL"), - AesKey: os.Getenv("LOG_AES_KEY"), - ProxyUrl: os.Getenv("DISCORD_PROXY_URL"), - RenderServiceUrl: os.Getenv("RENDER_SERVICE_URL"), + Id: botId, + Token: os.Getenv("BOT_TOKEN"), + PremiumLookupProxyUrl: os.Getenv("PREMIUM_PROXY_URL"), + PremiumLookupProxyKey: os.Getenv("PREMIUM_PROXY_KEY"), + ObjectStore: os.Getenv("LOG_ARCHIVER_URL"), + AesKey: os.Getenv("LOG_AES_KEY"), + ProxyUrl: os.Getenv("DISCORD_PROXY_URL"), + RenderServiceUrl: os.Getenv("RENDER_SERVICE_URL"), + ImageProxySecret: os.Getenv("IMAGE_PROXY_SECRET"), + PublicIntegrationRequestWebhookId: publicIntegrationRequestWebhookId, + PublicIntegrationRequestWebhookToken: os.Getenv("PUBLIC_INTEGRATION_REQUEST_WEBHOOK_TOKEN"), }, Redis: Redis{ Host: os.Getenv("REDIS_HOST"), diff --git a/database/database.go b/database/database.go index b4bb7fc..3d69de6 100644 --- a/database/database.go +++ b/database/database.go @@ -13,7 +13,8 @@ import ( var Client *database.Database func ConnectToDatabase() { - config, err := pgxpool.ParseConfig(config.Conf.Database.Uri); if err != nil { + config, err := pgxpool.ParseConfig(config.Conf.Database.Uri) + if err != nil { panic(err) } diff --git a/frontend/public/assets/img/grey.png b/frontend/public/assets/img/grey.png new file mode 100644 index 0000000000000000000000000000000000000000..208219bde07c37f5a02cf8bcf36f9d74c7823609 GIT binary patch literal 26735 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�ns|uxU(iZO9nMrl=Hk%KqJtcJVJ-G}A-NpEXTbnD4Sq#*52i*!zU%og0QD z4MEZl>^?|^XXL<$9_P^XuPN;^`4j%7e0zpRbywDVp*#}9C%&f!J@oZeKUGP6wcf$N zj)Q;-O5}h8QR&HgeSLaojpT34y10qjE9}Sc51t^2^%w=`*}wtn7wZAGILuwlSxIAE zN6M1<{NL}Ihp&#fm~$G|`L7thQcrfk)C0UXUm1Pu-O9rMR?qh5Y<>5s{f_{()2CY^ z&hm(p8zs8dB9Qn`M$e-#zgvp-jgIRrg`7y~?P0_+BzBFBTHc>G{oyowW-2+;$uX{N zQ-&++YzMYAPbYy1O!V~5zR^vAB%%4Vi&75F_7bn36?9PQI2T*+j@P*UW@fwnrF)y8h_5qyADPEMFtQ;I%SbM_!Lb?q2mKp$c6 z#B7r+eX)F4mI8HL6llnwNwdI+{j!<4UtbJ?ji`%s=x9kD2Wa_1jpYYr&Kqq{lDoG< zoE0u@W)$UIbqjE%Z+9A8W7wOIW%L$ytgotl94Lvk{L=Da`Uq_I7jgHNeH28US2u;i z3~s>FCbg0}r5S--d>x6`++LS4$M)e^iXWaO`l~TS zzfO92^DuOu)Q#;fp8CMe?vct_lWk%oc7oJE>OHO7YuzoDyf7Sxo%>KZuFf70m>Bmy zCQAx`kqn>B97oggR&nZjeF3O`u}iq)ihKlDNo6J1rQYDj+jQonWtNx5!6HxeBQM97 z;m#V%EQJMf+=^-<1!#R76+4P~xH3-=gvKp%sx4Qp7lpNUo&2=lmy8%YeZVu5`>lto zwm_cpIP5;rS!fk*+C`c2z2fx+Nn?9_I1xG5rpTA?R>uxid^N=G6oq_rf~%?R5I-M{ z1DwVYNy{m0nuDu;y>K_Pk4nZF+1?k6paQd+?oz#+6-7M3)~p7vfRIP9(S27lK^%u% zjm(&W!nm7Y_$O@~s4Twp3I{vZe`5mOlz|uqj2$w#-gda*RK)ofo2V#cU#C>d!5;n7 zKgY6W2N2$-j%^rI4zn9vtPb6qUkvkawJ&69laR`A?7gic(rBJ4m#pfTm2I~VDEtZQ zPEyxDZ0ogos74K)(4a0dQ`>9ize?|!#S}&V!tsS$jAWRzHBPfEP2xjTbzXaQGwqy__}0|{mAQO)y&CX z>s(2&ye#3ZFqO?83j7f#0kyA6UIPg|qP4$1F00$WPV z?254r6YaP)5${FDzP_mP+IVg0-pz4Q;ZVf*m6r14?0w1M%MKk&_Tlr974Yvnbw$Rz zkH#X!N+%km7lxm+xJ4iQq~RS{Ca2*yc=X(F6~tj=w~-+0P26evyfQtP_-0R@)oys; z!gb%pta6tl-SEr?4P|ryYZc8_@pblhi3ykR$5!0K15V!M-c&5-AEeolkFxASdDwh# z$By|w#l#Wa_Y>r@K^)lWYye*oQ~YUd_D`B^Y;5>h4$&nIM#}SVyLWTDoHCylyd)A5(9rFgo)p!<@L? zlkdA^kYa`>}d!SG*MYr(L3nRv-n`malohqM~kdSgLk$F&K9(^o&l9s`x! z&+1Z%zj{u4PtrWRE=rB$* zp5o4=y7YDA$I-zaY!T-txRaKAmD1^ioA@brdAIqD7a+TL;|uJ4;>3Jk`JwrUYl6LgNKmFhyE*UF-ZoC(CAB4~lDPz4b_xJP0moE~3owjge_kh|? z#hG*(dptikI)v53yVsUuk8>G5$YjxwmsymTefTTWh)Dc?oyzd2JI)9pt} zvOYv0t6vKdU`ZryG!0Qy;rN6quk~kfGh8fSELr-Ugb-(Amfq1=5?WkOu^@XnE6I(7 z1Qp=U`RT-z^8zl&Ym6^#l(VlZGjYpYUda$3x~7SRBh~>@u*;fRtW{BGrBCYBpsCbr`K?>j;^LWHHfRDix`_=jv{!2>0iB2isoL zH+$ap08Y@0=yrM~erlz=#1(JHZ z%MYC7%6oBO6s+~)Ro{tmmq|AhR3E-)nj+Hzarqog9vSgR4Mxs9&&ihA+g$#QM$TA3 z&}?gM+(4%V`H2sCdwdCF)`5ggz4XOe_kGm%lE7!~3jXL!^g_TjdrsBz2-&D90_^uJ zw(V>Ao@AIkuxjm-(~Cr1CuF`EL&g~5=8TRAqqL|v*6HFhh8wY>!!ENl+4>C6g9GO% zRonIUx-_v|+!r3wx*iX$zenVPVMr3kr0&1tV6yPYkC_kEgH_R}ne*w71@1Tx%h=3m zK)R^+7_xs^hC1i6fm0@O2Su34L^&~qDzad}T%D@S-t*@@9B^i7z)1a`LM%)`&=bh! zWf>FagQn`NFYjDY$BPDWrH__LM6c|7&0TPy;u65dV_;XyEt5h?8Bx0z8tKi0VAtct zzUNRobOvYL*WfMAyAMwz$9#m()o`F;;$BXlR3~DiI$QFCl4xNGpA=4_LV4WKBgZ<~ zD=Ow9Oqrz4M7g&ey+7vC{}7>)4z~fB9yJk2bm(8-C*B?M0a+35y)+&(tJ@Zb0v9}} z;^B^e4 zp?DGvT`#X=$`D}9=`O`mnUuG;D!O;?S+%fUw*+OGO^Vm~k)RH$Pj>>FhtZcU8W3iU z#nuDk-62zHIOl;Q#q(pc>I7O>GQXTpWuZsVs}@I7bb}7y|4QG6De#Wj$V%= zEYh|?mA7So%qG?R0+84BSYZPc(&ukDy_pLmBG%PU8Z14fYr-3u;q^LA|$Nw^z^EU0~5>U>OaFMu1B*6!grN-0#Rn z=ZSGbpsefX?|aEnTW1f|bO%>n@(5Zpqd>TV;YCN~p6IlKWt7@99BsV#96KlbblMA# zM&sxHQ=Ko>|E-yn_e(5V9(g8@!FS7&<26Ebt`ZmxJL$x|SBISuZh0GGmKpYz=tt;X zR&~>l>W_1$BG<;AXEzJ0^Z#;AeUHxMj|26b+}M6^J?c`(cqwF$T9k{auhA1YSHyj- zOrz=85kIElqX@4@RCAHG-Rx56a_b9Bm*r~j$+SgqhqB+A@aN^wu)cE-ItcR4RBCisi>Y}}g=$&ZM zB05nrI#D7a>O?0&^gdej-Xla1L>(*&3Y@*dB7|AzbNe(_)vY3+kU6gSwzW}qxoX_thpidCu^-t?ewoJJ}^nT z-=((+f!3hmWu1f|C7<#ouZZot;t-c4cEQVCH*6*7yj3QSh_rYCxODe}X$jE4dKS(+ zTQ_4f(nt(eU-bEU8vF#o>WZ`+TROjbl;NFuE0{K_@=JkR@*?OkdV}0r&WWM$le?A+ z>RE$`dxAsL>uvoQvFEn!s2b*AkAYgf`Gb!K)#W&h%`6KjW4F1piD&Tbjw$(OT5xee zwS7rQfLVJ#^<&9uBYLaf&wY=~?hNoBH*-@%Oo?CZk%tWOez8GHtNl_r6*VK+&Lo0q z)Iwj|0i`UfWct+4XODN3MCRh(s6bR?1wX}JyrBj$iMEGP3cYtK8z|Xn{8!J!Z5rUlT;# zs3V}D&@rY-e?>`|w7vrAlYZVAF=55HqNMq5jX-wMM4&h^u=!sQnZ*jVT#k+q%F<4q z!H$X+An$CllH8t&vV!McVZqNPL(ivwLV7!#uBr+mJCti&(YyVD2vHu8^){v94}43l zc3WD$<@|-=_o}W_4f{q&x$p2h*s-(%TgL4dGXin?vHdV!0&1h$h}nzQHNXg?)`=51 z$jv%87qz>;Uo74mM8`rC2=kM?;~kpFOjMMv8nBHVV%{)(;WUFY5t7ql^=2`guxZR6lATadG@MpOdKOy$)uZZ>99RJ<3V$7wgU{5NJg{GgSEI5~7+{ zLcQKmLdT&VN_{H&d=QFPH}ibavHgjPotxCGd)1rZk<}K7qPxhF9-aK?u>A+)jfZY_ zyHPXBz@h#Yv^frTR5r*sI;B_6ubnZNVWj@jbwxo>w<>O7hkO~}iuqk6XIrZIrtZWM zEbt6VGWwrOl5dP+=6*nLjn2EAFk*sLQwBy|*h_9(212){AXScfrw~hn#Taau|A z$rprk%yF-!yEC;ok@J6V`BF}8nZ%SK`fS9H;vhsI^5eee#FGoqeCDU8 zR?Y61S8*sK(AJsejE)m^$_k^qbJWu14^MhLLu$1)rFbf!3hi*3Chm2>^;J+^B=Brx z;*S2p05QvsDTed`HBg+~+(h0qbijp)H0c5j9+zEA8Q`m^9+$zCm-}0GoYc0q&K_cY3j8DHcf;6-!WK~NKjAU zmE(`+f+4(JwvqNh9)eqWE_rx@%6`bj3zuR~=jH7l52`lC%%HaL@-ft|u9(^{v?8e= z(<5Chu_{e1k0@R^`E_R>2AO--akLsnDBrdj`A5f5Nsc;%E&$!CfNK>R)D>0x^;Xc` zyb2<+$wMT(R;}wcr6gHBPG$aXQ7e0=^{?x7UyxhqdrpVe|oA8^xy+6CFOMgGjyu^Io%d=BRBh!> zCLfkYY9?rMhGZ$|CvS(r@;iBWd8An5+l-_fE>n+RR8KJ@TeBa>vK|o0DnL_!>NH%Z zthcxzCzhg`@h5y_oHnDd=SDfHppzD|S((}qT7vH#T$9f&losfpdoksHs-|%}oVEkB zkHxhNySE{}sOd^2*ciXQz$d>*|COY9XI%$(%{ovJi*b_0A@4Q)Q4u2#`@F0g1suLS z2S7j;Hj8_2Qn8h$zDA?Yr^M&dvE1zv$`8yUb==^-b23MR*am$QN*@K+c_BvmgfkHN z(pPGx{2TktuB=}J6*d|y>RN&nFC>2Ck<@xmSMHfP90|_GF%3>xUQVVx^5K1_I47yu zPNO{wb)PPvFAw8L1;=3FdtEk|&AtGdfBaOhMdKsSU3ITsS?8W&A(A8c!M;~HJNf2W zZh+_9;HwZ*4U!sw_HVLzh*D1un8*<3;nX@usP7az4ckt1as_j{(>zZ7g z>kh3UzRT(#5|li2VIZX;!eZU})ETVA)(X%@?i(pxq{7J~IR+h2;)BBK2(8*nYH74j zrj5>JaLGZ+Tm9DN!6DA`qnPFi`JQK0nwK(Sl+^w2q)4%UFy1E42ApQ+Cy!fY^l+hl9bdgUqf|rwk_W5^k;BaXQ}rZ!>JVO7^Hj% zPuIqQozEO&$M!W8^TAfhQp%zL1N&V1#e1 z;-!7tQEe(pHY>ps`mNElaVxxxpq)|)xkXs;BIVsOZ-s~w__I#pEfe*d>`kurOFDI0 zccEjs<<{xbBBDsHpTQ^ACnt@12i5I8L1b7FhzN;soj6zngWU-JN%Qs7J?o`pcvjgj z>TdHIgLRslKK_vO7LMw(kilpS*5YB;g^xeJ@!Y(;M^OV-eEVCCw3DtEBu8mWgUq7c!i?43c_uj$j-AUjOu1t^JIjOpBNbd@fqjrL`5e`+CJ@AjL1Gph8) zwkk;>%!V#*j%A&IYqRg8vA#5|!`ksz+zcGqM^DA2n>r`#q!8l*)r%ff!7dk;TBX)y z4U8c#>ko#${n&;Q4dl7Dy@?nAAG(Qqmuby8(0ns?k!K5+M19M$-!!-C%9Iqn%qv z6SuQNv6y&0q?gV_Ur^asyWWowEid^L#B0gZMMMKyC6v4hx3}yAjKf*$;&iKrK5Yi8 zo=f`5olHAtg{Hm0kK1gyMh~@GS|XdR#NKLfdU|>;OQl22BpTyZ-!(Cb+U4`h3RMsu ziooM7I{B*%xs}_>n3+*9v&wZB*anF#SBpGznT`LnbYUhjOq`PtS?u6aX<#z2`HJ*{ zMnLrhUse%F}$EoI&0OY*aVA%l$Eg3B!YlMf`>zG5$wO zoF{PVWuL~KAOFO#qu1cZdKs!ggo*mJH;6Q0!SswmDp5jZP44d_F>05q zyAG>GSuN_6QbIyV)ZkklKJtSpZx>ER-PYY)M!h{0Bi5wh;V(=@hffCpngFLLv0FWC z!cOkw;BdPBH33gbX(_?(Ess=`_AWDV_nDeAPQ19uXpu%b7LxdwBAQ|A54J_lP}q_I zSCJg*cni?cb7ronxh-KVVf}PDp75ecK?4qT(AIb#SmAK*UTciUyf^d|^x?Db=x4rU z%j`LYjXC(De~F~piZtM8Fx5?1=;!}*fXJC_#9B6ozJH$cw$S2}s1E%YHS3>yh6Vcu zmfYi!rIJsMAryOjeq!Vwe}GGrV2w$0pLivDJlBV*jtOh1!=p0TJz1jcI(5Ym+FXfO z9~E4`er^Zc2DIOVHC_=Xh~i)?{SHb#6`k2o;qz$d5u;G-Gub-$&sY(-l)I#i&Xx%6 zL%uw<0(?D-Bp*eeXCKTE|ja@yP8pe1$&c}{s!l% z@XjOG@1F#){yZH-;!Q_gzdtq@{~Vc;Ol2Xzufha-4E9`WsYu5{xBNiG8^}J)!W(_ zYMCqsFa)()j-Rk9fWEwe|I_>GupX0+qf!Le;JtsA-c3)D#ksd-VwlQRzj@jDDhN5R9VG4mDPwGiXH4MIcDhJMV6Md747*h%Uhs0aKAbY zj8+(ueCjDNu)1iuMXvqrtVZ z3q+9Mwbfra+8mXBAlY)w%U0I-yMI>=WB`_ti`D~IcK@Z4rGYO#p+c8qU&RjAJkDYG zFlR{qus-B#6>sPrBtI$lXo&^H$Wf}#2V`obP&*$1+sKvbMdz$e(iS+3eaku{hFe^x zE$$=ZlgZK~1l$HEhYb_?d4>*K=@sqg^0_O~AQu|gZk8aHiujwa&9|_|h0RC|Rw6Ui z2p;6CPbd|e`C!HOq$>{Ikkima5nXbpDX2ROf-H@6G9xQDQIo5(^>bfy;Gi9q1z-@G zUiZT(J}%%|;USpweCbrYL+KvXc6_g)d!RYmDT6CdVKsO8cay__%qBjqaLymAgKve} zpxfypjbDOKv5;`7mjHgykMC(8+G!}Tl}@pH;d1wVKVs~tp)oG&LkpUHxtYB64YYqa zu7X_vJI}nfpq()F5eqqLAXamsRmyDba(d=;-h3Q1=65b@NWP2T3-8)28arN4BdTV0 zhLq{^R@w{-cu5umHHaY?tk)K*eLj8UPZNoyCUTW|rg0Efp$)UO4|BT`zT7&RpiqJk z3RU9VP7^x}*QvEJ7{GuS>_58ctRhs!v~2}|aK={cq&EtvVL)Vl+~4%*3L;~F#ucR! za7QrDQZ^6EWz=s6Pn5a{-|Ysu2-w!C*e=9QP~&G{a8`D`;ED%HYZvz{)$wd9;g$4N z!kc5=cGQf3isi|>Pecgq2QhHPO4-~e(?1IR@_J1$3YB~5jsZ-X7NZY+{}_pXF^hV| zhTeu{=_W$Q;moEW#p~@5MwV9e50%#b=c#V2S&T=@-8Qbr`fg|HK;ggVVlR$0K482` z>t+=e5*&rLSRB)Bzmc901Q4ybg>zP9B2EC&YWc~n46($L_t(V1iynZd6opI%VLr-{$c-EL*5tSA0qhivSlAA}CUbr6l;O0~wgE8X7u*aU!@3H*SJ* zPc1MTqlUuqO|d;)!vTID9wku9LckKFc1jSfpZ@86!?%JdysJ&pI(-P;((Qr5KYp|w zL1f713v$1v9e3N{i>xV=Pt(5%SPE`Y&5+#bku*~_k=Wp3rOoM9N?0CFSBmYKciPxw za0w+%jUPY@rNi(lW0iah~xrO76xm)q( zMSd!zh{{=4TcF3M3T2olM-7}hJuw}@XRZ5fBs+R_FH2UR**W-`ai3kdAe~ynl)1i< zMbTsd$1D9tJ4tw}PkcIO=!!|ry}cxs>C9av5bT`6_4E0*K_qN+lHNytt}?WzpnY6r z+hZNX?k2*}vAuf!qGN?(ECeMi_B10!ol4 zuq?as_eS3H9O*Tb=j+YQ`xIX48QIOi9+$}MH~)-bj!jbar5d;jas73cQe=yE(O3%KsNsHn?EDfeunl{$>X3ThIUr)>^%RRcsVI!s>K3QfTfo-c$0@6` z)~T)q`xs|l=KvGP+|Z2t7dE1Uj<&aAPvgFl9It5zOSW;?XVT~h)Tsozya7ab&sKv| z+hJOXedmuwD8tn&jtnA2_K2@H!i0wsazsI@_L9Em+#0&3$^~B2wC^pU7W?2)5^n3S z$1y}@X0w)BfHtZB((9$0C|)QUqCfw(v43h|GN0_jJz4Uz0-_Og*-DDIvEVI&` zNrP=g;8lNIU>|`tGe1AO+>dwO@&IsZ6}RvYq~*I9eYIts_7|ff2W*nMy^6;a#Npqf zQGstZm1&xi-s+H+k1*2h6yg|tUu3lq;37(B5NBbeq8A_T$j@lDB9aJgYYC)-~kERGd4*u z22e=cGqG+f5z2t^g}HQ5UIAVa)Pifw==({cvHk6asM?B9EaTjBYUUV-X54Q2aWn}( z6rM|Ll)7F=^HBCsBJwb!4+Ot~>#d2URN2SPqLkqUQmKhrcUM`(Em`&%hu;eiYvYsG zN(K&2DZP)e&gf0VHto|2B9^$xiB7@AB00P?;@OngW#6kib+}Kp@UQ2HNe^t4Ak1Gz zysCVpL(5_BQd_K}Ie@KJUg54}^_l$lt3N;M=!=Eh*1ZXPg3+Db-3$#J3;BYF0~5L2 z?Wbo>lzGd_R0}e#OA)kjDD@T9+HTP2?Z8!rU?}Fl%z6d4rkb#qZv`ZAl9elJ%EZ#R z#PmlaqF{!)w`{vt4S1d=TtQ;KNfZMm7_vzuQmct0Q^%a^ixIK6%9px z38Uqz&Buk5zg13C8b9UanQszff%5hVU|8Moeo{%sq{zwCk(LTO{AY#vbk`oiS=HFs zxaol3Gd^!_BL=~P$O6G?Ih~22d0!NfTpZK)>x2cIm7G|&$v>4iiYP){E_aPP$B#1* z4yr90L3QSv``?1Zd!9SU32eT@F{EBxc@RZ(%M}7vO%{QQ>C;5^+T5Vv*Nn;OVaNC= z8G%jg1e~y=^&I(ahee0>B^g{2r*y8L2*<78RwmWCb|tepfdOm6vGy#l`vV5={VJ-J zpC^pQ*I0SNdLkPCdA~}7);A#D!vAT&9P!J|Rh*$S$H8awNZ>Bl@3MYT4@u|g z)dN*mhWHlyc|*fQI~c!7xk#qN|8$OsGnD(a>!5wc84GTtIct~dNXEd8i}}W2D(u{w z=*B)d>UC(hBK&ehv?BfhC@(p~@R39#Hejt|AzWVy7B|}U_-vD%<3#lkm)%}xp4_7c zK14$xcOaz2bdxF%2IT1oc|p>|zv*k-`z=l!4UL9zWvvm8K_U>3$f&XI7-VmG1Y|M; zh1DaoR;#v*ZI5KoP1$~Z%W!Q=DR)5L z{t{=@$}^Y}1gZXKo6$r8Zm*~5AZ=Xi;AZ9@fkRVUCIC;NBbx;CQ4db$*5Hv z-dK3& zg>!;ei#RH$W8GxsM?Ul0mus5-wbVK!fbty3{VUMwL7fq_n6CbzKYoa982Z6RNyZ| zic!E~<+P|PMb^~BLbBH0*DvPZ3y?~IYK?y-{>5`XBTFOkIGGRF^uTNeeAe)aC;`MY z>rU|ABeX?CyD256*)w9`zr7Oa@h#!3EQ$x=*2g1dz784_ls8lJv_yXwi5+XqpQA+bA-QZ%Q)&gyqu0&;!6C4R)!J z?#zX6Spq1b?9~?BZ>w0oX5U{%?qs}-SfV?91i)EpRhWznoF~5^dylMG%Ule&^EXU6 zY0$?*lpa0KqA-<0Y?{2Jy9iS0SBx+e+)BbsQ-5vr*53Q$0)C7ZVj(HjRpPY$iR|3> zfx@Mtv2-pATG#Wo&k=iZm%%Qtd@<>8d*KwykS@X(SH57%FI3J8l{6Hm3P7T;XUBN? zB<5>+&q7ieL29NGYXuF3tCC<%=Q>OHKT=S(bX8ALW-1EFml#7H>;jR;OqV%!4%5uj z$R6ZVEU-UsJ5ACR*$NK65;Yaz@+30nr95`7@)N#2S>u^Rz)b|WHfc>q_Q>>q&0O;S zl^%r5g(*X(qVT-ohNhaKW!K2xp|10*=72jhC(6JD!s;T9nig(Z-5ZypcFkjQUNk_0 z1ePSVxoiTN+QB^Ir}$mU6gQz^%}1{FWiU%EC$eAJtf>?XbF#F?)|THL6~|k&3^!LL zqAarXft5hR3;C-%6E8Q%R;+lOyaNi@)=_)2hqL4ks#H3HkC9orJ##h3&bBAs;_>&e zkP{jnd7~LeuZAHJgukxaD>Tu>LVgX?RV?AkIjx@&rIVLsSj&C0JA*$1*nmYKzErPE zzTB$O+i(JhSq{nQ8G_A6!WvK**M_f=B=HOdY$6pO_@(Y$vnsR=FJ{;i2PNO<8LCx~ zK+OQY-~(C6frn}$8WtaS0dx<>qj}7aep|Lg@HUK8zl(A1SDGvH>Y6Az=r^wwMj};tsJKTcP99<-gUXa4K<3_Gi=8F?V3qAGjp-3v z#a8TJ2KUO>>C@CpS1H}YpR~yg)Kk_PQ(Y&0!f$`W%l~v^?p6p}>IOwsvqXEMT0g1+D#j!6` zLu;r1sbe?Q!v=`bn=|MlS0}KMe!+m&H2vnSEafV4BLF0h`(Z)3)xHJ}BO+MH+vSrP zCKo3iU!H^MC+!AK(c$HR->Lz~uN|NfW}osu1%swqr$axA>2dfSIA{{9u}9 zb-mu=UIw7AFGa?B8NR4A9JKQQ`YPN+&=BFx>$jK>4XD9=E0WsVwjiXl2r**0He_ds zyK-*;h9ANApD%LKfwI;a(#&sSkoy5F{S;fsZ;Fie1$kEI`G0 zIejC6;`6lPAYf+zCu?b?H`+blHvEtIJXr+>?yhP&e8cyV`4t#A>GY%l#%>kehVjVG z4d{ChAK(EP)XESh<0&BnpqL<5tbz9w#`E%(ZwMSHL{oXy*hzWbv;PDAtrJm4u%4-e6eFCtbt;m?LmAe1ovVjd z777&8(_GXijDzM&I==w#umBRzrz2bu-{M%GDSfPRp2S!`yP7oqd7hOq9QglQtS{8N zc#PvH4cY(mA~R=+dyQdiqJnvl3c9+?y_EOtDana*NRM6G8|kR?dCs{FHb9opW1poW zlpzz8&80#HY*{2lgrO zV_f3Jm)eTgI~v{&J|Dqfn=y6KKS`iHsd`^?CIDX7qQ0msIm84Y>F{Kfw)9eX2rLb8 zCL8(Z=dO_1ixgUGf7`Mkar>qRP=sQqJe&KA~zvkU;im*pS$Sw z2HI3?;zcP}=H)Znbemzfsgv|flLvYEy)^qfE%9~P+FyZ#CTKi)TO5FDnIiCbDUU{S z8I~-v&rS}88q@RDp?Eo{0ZGvtn3b}<>fv$UZ`W6P=R_o7um(hQCjhb|ss-Kql(t%+H3L|s!H zV5g{9gfcIe;Zz|}IelQfx<(Vp12`QzT6+BB?Zy#6kUQjtegb0zwVlb_xPbPqoVm%m_%c@R)trruUf zz#0VNXQ!VL0MBqUaRR0qO*iBxBJ5#OD|YP%ni|*?#pnSMn_QDjc3mdX1HK`WO#T@# zj!Pp^CgQ+i$`F8SL7PO<- +
+ + \ No newline at end of file diff --git a/frontend/src/components/Button.svelte b/frontend/src/components/Button.svelte index c151a7f..bbd42c1 100644 --- a/frontend/src/components/Button.svelte +++ b/frontend/src/components/Button.svelte @@ -1,10 +1,12 @@ - \ No newline at end of file diff --git a/frontend/src/components/ConfirmationModal.svelte b/frontend/src/components/ConfirmationModal.svelte new file mode 100644 index 0000000..c291971 --- /dev/null +++ b/frontend/src/components/ConfirmationModal.svelte @@ -0,0 +1,73 @@ + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/IntegrationEditor.svelte b/frontend/src/components/IntegrationEditor.svelte new file mode 100644 index 0000000..3b887aa --- /dev/null +++ b/frontend/src/components/IntegrationEditor.svelte @@ -0,0 +1,501 @@ +{#if deleteConfirmationOpen} + publicConfirmationOpen = false} + on:confirm={dispatchDelete}> + Are you sure you want to delete your integration {data.name}? + Delete + +{/if} + +{#if publicConfirmationOpen} + deleteConfirmationOpen = false} + on:confirm={dispatchMakePublic}> +

Are you sure you want to make your integration {data.name} public? Everyone will be able to + add it to their servers.

+ Confirm +
+{/if} + +
+
+ {#if editingMetadata || editMode} +
+