From 07f0856fbaf72a405c0af095ef522e29c07636b5 Mon Sep 17 00:00:00 2001 From: Posi Adedeji <39467790+theedigerati@users.noreply.github.com> Date: Tue, 3 Dec 2024 13:16:40 +0100 Subject: [PATCH] fix: add provider input validation & type safety (#2430) Co-authored-by: Matvey Kukuy Co-authored-by: Kirill Chernakov --- keep-ui/app/(keep)/providers/form-fields.tsx | 522 ++++++++++ .../app/(keep)/providers/form-validation.ts | 343 +++++++ .../(keep)/providers/provider-form-scopes.tsx | 25 +- .../app/(keep)/providers/provider-form.tsx | 907 ++++++------------ .../app/(keep)/providers/providers-tiles.tsx | 42 +- keep-ui/app/(keep)/providers/providers.tsx | 28 +- .../app/(keep)/workflows/workflow-tile.tsx | 33 - keep-ui/package-lock.json | 1 + keep-ui/package.json | 1 + keep-ui/shared/lib/encodings.ts | 25 + keep/parser/parser.py | 3 +- .../appdynamics_provider.py | 3 +- .../auth0_provider/auth0_provider.py | 23 +- keep/providers/base/base_provider.py | 34 +- .../bigquery_provider/bigquery_provider.py | 2 +- .../centreon_provider/centreon_provider.py | 13 +- .../cilium_provider/cilium_provider.py | 7 +- .../clickhouse_provider.py | 18 +- .../datadog_provider/datadog_provider.py | 22 +- .../discord_provider/discord_provider.py | 4 +- .../elastic_provider/elastic_provider.py | 39 +- .../gcpmonitoring_provider.py | 2 +- .../gitlab_provider/gitlab_provider.py | 5 +- keep/providers/gke_provider/gke_provider.py | 2 +- .../google_chat_provider.py | 8 +- .../grafana_incident_provider.py | 5 +- .../grafana_oncall_provider.py | 12 +- .../grafana_provider/grafana_provider.py | 15 +- .../graylog_provider/graylog_provider.py | 3 +- .../ilert_provider/ilert_provider.py | 25 +- keep/providers/jira_provider/jira_provider.py | 18 +- .../jiraonprem_provider.py | 3 +- .../kafka_provider/kafka_provider.py | 8 +- .../kibana_provider/kibana_provider.py | 25 +- .../kubernetes_provider.py | 6 +- .../mattermost_provider.py | 5 +- .../mongodb_provider/mongodb_provider.py | 15 +- .../mysql_provider/mysql_provider.py | 9 +- .../newrelic_provider/newrelic_provider.py | 18 +- keep/providers/ntfy_provider/ntfy_provider.py | 3 +- .../openobserve_provider.py | 28 +- .../openshift_provider/openshift_provider.py | 8 +- .../opsgenie_provider/opsgenie_provider.py | 3 +- .../postgres_provider/postgres_provider.py | 18 +- .../prometheus_provider.py | 3 +- .../redmine_provider/redmine_provider.py | 3 +- .../sentry_provider/sentry_provider.py | 4 +- .../servicenow_provider.py | 11 +- .../site24x7_provider/site24x7_provider.py | 7 +- .../slack_provider/slack_provider.py | 7 +- keep/providers/smtp_provider/smtp_provider.py | 7 +- .../splunk_provider/splunk_provider.py | 25 +- .../squadcast_provider/squadcast_provider.py | 14 +- keep/providers/ssh_provider/ssh_provider.py | 18 +- .../teams_provider/teams_provider.py | 4 +- .../uptimekuma_provider.py | 13 +- .../victoriametrics_provider.py | 15 +- .../webhook_provider/webhook_provider.py | 3 +- .../zabbix_provider/zabbix_provider.py | 3 +- keep/validation/__init__.py | 0 keep/validation/fields.py | 163 ++++ tests/e2e_tests/test_end_to_end.py | 154 ++- tests/test_provider_validation_fields.py | 171 ++++ 63 files changed, 2030 insertions(+), 934 deletions(-) create mode 100644 keep-ui/app/(keep)/providers/form-fields.tsx create mode 100644 keep-ui/app/(keep)/providers/form-validation.ts create mode 100644 keep-ui/shared/lib/encodings.ts create mode 100644 keep/validation/__init__.py create mode 100644 keep/validation/fields.py create mode 100644 tests/test_provider_validation_fields.py diff --git a/keep-ui/app/(keep)/providers/form-fields.tsx b/keep-ui/app/(keep)/providers/form-fields.tsx new file mode 100644 index 000000000..6ab5090b7 --- /dev/null +++ b/keep-ui/app/(keep)/providers/form-fields.tsx @@ -0,0 +1,522 @@ +import { useMemo, useRef, useState } from "react"; +import { + Provider, + ProviderAuthConfig, + ProviderFormData, + ProviderFormKVData, + ProviderFormValue, + ProviderInputErrors, +} from "./providers"; +import { + Title, + Text, + Button, + Callout, + Icon, + Subtitle, + Divider, + TextInput, + Select, + SelectItem, + Card, + Tab, + TabList, + TabGroup, + TabPanel, + TabPanels, + Accordion, + AccordionHeader, + AccordionBody, + Badge, + Switch, +} from "@tremor/react"; +import { + QuestionMarkCircleIcon, + ArrowLongRightIcon, + ArrowLongLeftIcon, + ArrowTopRightOnSquareIcon, + ArrowDownOnSquareIcon, + GlobeAltIcon, + DocumentTextIcon, + PlusIcon, + TrashIcon, +} from "@heroicons/react/24/outline"; + +export function getRequiredConfigs( + config: Provider["config"] +): Provider["config"] { + const configs = Object.entries(config).filter( + ([_, config]) => config.required && !config.config_main_group + ); + return Object.fromEntries(configs); +} + +export function getOptionalConfigs( + config: Provider["config"] +): Provider["config"] { + const configs = Object.entries(config).filter( + ([_, config]) => + !config.required && !config.hidden && !config.config_main_group + ); + return Object.fromEntries(configs); +} + +function getConfigGroup(type: "config_main_group" | "config_sub_group") { + return (configs: Provider["config"]) => { + return Object.entries(configs).reduce( + (acc: Record, [key, config]) => { + const group = config[type]; + if (!group) return acc; + acc[group] ??= {}; + acc[group][key] = config; + return acc; + }, + {} + ); + }; +} + +export const getConfigByMainGroup = getConfigGroup("config_main_group"); +export const getConfigBySubGroup = getConfigGroup("config_sub_group"); + +export function GroupFields({ + groupName, + fields, + data, + errors, + disabled, + onChange, +}: { + groupName: string; + fields: Provider["config"]; + data: ProviderFormData; + errors: ProviderInputErrors; + disabled: boolean; + onChange: (key: string, value: ProviderFormValue) => void; +}) { + const subGroups = useMemo(() => getConfigBySubGroup(fields), [fields]); + + if (Object.keys(subGroups).length === 0) { + // If no subgroups, render fields directly + return ( + + {groupName} + {Object.entries(fields).map(([field, config]) => ( +
+ +
+ ))} +
+ ); + } + + return ( + + {groupName} + + + {Object.keys(subGroups).map((name) => ( + + {name} + + ))} + + + {Object.entries(subGroups).map(([name, subGroup]) => ( + + {Object.entries(subGroup).map(([field, config]) => ( +
+ +
+ ))} +
+ ))} +
+
+
+ ); +} + +export function FormField({ + id, + config, + value, + error, + disabled, + title, + onChange, +}: { + id: string; + config: ProviderAuthConfig; + value: ProviderFormValue; + error?: string; + disabled: boolean; + title?: string; + onChange: (key: string, value: ProviderFormValue) => void; +}) { + function handleInputChange(event: React.ChangeEvent) { + let value; + const files = event.target.files; + const name = event.target.name; + + // If the input is a file, retrieve the file object, otherwise retrieve the value + if (files && files.length > 0) { + value = files[0]; // Assumes single file upload + } else { + value = event.target.value; + } + + onChange(name, value); + } + + switch (config.type) { + case "select": + return ( + onChange(id, value)} + /> + ); + case "form": + return ( + onChange(id, data)} + onChange={(value) => onChange(id, value)} + /> + ); + case "file": + return ( + + ); + case "switch": + return ( + onChange(id, value)} + /> + ); + default: + return ( + + ); + } +} + +export function TextField({ + id, + config, + value, + error, + disabled, + title, + onChange, +}: { + id: string; + config: ProviderAuthConfig; + value: ProviderFormValue; + error?: string; + disabled: boolean; + title?: string; + onChange: (e: React.ChangeEvent) => void; +}) { + return ( + <> + + + + ); +} + +export function SelectField({ + id, + config, + value, + error, + disabled, + onChange, +}: { + id: string; + config: ProviderAuthConfig; + value: ProviderFormValue; + error?: string; + disabled: boolean; + onChange: (value: string) => void; +}) { + return ( + <> + + + + ); +} + +export function FileField({ + id, + config, + disabled, + error, + onChange, +}: { + id: string; + config: ProviderAuthConfig; + disabled: boolean; + error?: string; + onChange: (e: React.ChangeEvent) => void; +}) { + const [selected, setSelected] = useState(); + const ref = useRef(null); + + function handleClick(e: React.MouseEvent) { + e.preventDefault(); + if (ref.current) ref.current.click(); + } + + function handleChange(e: React.ChangeEvent) { + if (e.target.files && e.target.files[0]) { + setSelected(e.target.files[0].name); + } + onChange(e); + } + + return ( + <> + + + + {error && error?.length > 0 && ( +

{error}

+ )} + + ); +} + +export function KVForm({ + id, + config, + value, + error, + disabled, + onAdd, + onChange, +}: { + id: string; + config: ProviderAuthConfig; + value: ProviderFormValue; + error?: string; + disabled: boolean; + onAdd: (data: ProviderFormKVData) => void; + onChange: (value: ProviderFormKVData) => void; +}) { + function handleAdd() { + const newData = Array.isArray(value) + ? [...value, { key: "", value: "" }] + : [{ key: "", value: "" }]; + onAdd(newData); + } + + return ( +
+
+ + +
+ {Array.isArray(value) && } + {error && error?.length > 0 && ( +

{error}

+ )} +
+ ); +} + +export const KVInput = ({ + data, + onChange, +}: { + data: ProviderFormKVData; + onChange: (entries: ProviderFormKVData) => void; +}) => { + const handleEntryChange = (index: number, name: string, value: string) => { + const newEntries = data.map((entry, i) => + i === index ? { ...entry, [name]: value } : entry + ); + onChange(newEntries); + }; + + const removeEntry = (index: number) => { + const newEntries = data.filter((_, i) => i !== index); + onChange(newEntries); + }; + + return ( +
+ {data.map((entry, index) => ( +
+ handleEntryChange(index, "key", e.target.value)} + placeholder="Key" + className="mr-2" + /> + handleEntryChange(index, "value", e.target.value)} + placeholder="Value" + className="mr-2" + /> +
+ ))} +
+ ); +}; + +export function SwitchInput({ + id, + config, + value, + disabled, + onChange, +}: { + id: string; + config: ProviderAuthConfig; + value: ProviderFormValue; + disabled?: boolean; + onChange: (value: boolean) => void; +}) { + if (typeof value !== "boolean") return null; + + return ( +
+ + +
+ ); +} + +export function FieldLabel({ + id, + config, +}: { + id: string; + config: ProviderAuthConfig; +}) { + return ( + + ); +} diff --git a/keep-ui/app/(keep)/providers/form-validation.ts b/keep-ui/app/(keep)/providers/form-validation.ts new file mode 100644 index 000000000..cf1bf5e1b --- /dev/null +++ b/keep-ui/app/(keep)/providers/form-validation.ts @@ -0,0 +1,343 @@ +import { z } from "zod"; +import { Provider } from "./providers"; + +type URLOptions = { + protocols: string[]; + requireTld: boolean; + requireProtocol: boolean; + requirePort: boolean; + alllowMultihost: boolean; + validateLength: boolean; + maxLength: number; +}; + +type ValidatorRes = { success: true } | { success: false; msg: string }; + +const defaultURLOptions: URLOptions = { + protocols: [], + requireTld: false, + requireProtocol: true, + requirePort: false, + alllowMultihost: false, + validateLength: true, + maxLength: 2 ** 16, +}; + +function mergeOptions>( + defaults: T, + opts?: Partial +): T { + if (!opts) return defaults; + return { ...defaults, ...opts }; +} + +const error = (msg: string) => ({ success: false, msg }); +const urlError = error("Please provide a valid URL"); +const protocolError = error("A valid URL protocol is required"); +const relProtocolError = error("A protocol-relavie URL is not allowed"); +const multiProtocolError = error("URL cannot have more than one protocol"); +const missingPortError = error("A URL with a port number is required"); +const portError = error("Invalid port number"); +const hostError = error("Invalid URL host"); +const hostWildcardError = error("Wildcard in URL host is not allowed"); +const multihostError = error("Multiple hosts are not allowed"); +const multihostProtocolError = error("Invalid multihost protocol"); +const tldError = error( + "URL must contain a valid TLD e.g .com, .io, .dev, .net" +); + +function getProtocolError(protocols: URLOptions["protocols"]) { + if (protocols.length === 0) return protocolError; + if (protocols.length === 1) + return error(`A URL with \`${protocols[0]}\` protocol is required`); + if (protocols.length === 2) + return error( + `A URL with \`${protocols[0]}\` or \`${protocols[1]}\` protocol is required` + ); + const lst = protocols.length - 1; + const wrap = (acc: string, p: string) => acc + `\`${p}\``; + const optsStr = protocols.reduce( + (acc, p, i) => + i === lst + ? wrap(acc, p) + : i === lst - 1 + ? wrap(acc, p) + " or " + : wrap(acc, p) + ", ", + "" + ); + return error(`A URL with one of ${optsStr} protocols is required`); +} + +function isFQDN(str: string, options?: Partial): ValidatorRes { + const opts = mergeOptions(defaultURLOptions, options); + + if (str[str.length - 1] === ".") return hostError; // trailing dot not allowed + if (str.indexOf("*.") === 0) return hostWildcardError; // wildcard not allowed + + const parts = str.split("."); + const tld = parts[parts.length - 1]; + const tldRegex = + /^([a-z\u00A1-\u00A8\u00AA-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]{2,}|xn[a-z0-9-]{2,})$/i; + + if ( + opts.requireTld && + (parts.length < 2 || !tldRegex.test(tld) || /\s/.test(tld)) + ) + return tldError; + + const partsValid = parts.every((part) => { + if (!/^[a-z_\u00a1-\uffff0-9-]+$/i.test(part)) { + return false; + } + + // disallow full-width chars + if (/[\uff01-\uff5e]/.test(part)) { + return false; + } + + // disallow parts starting or ending with hyphen + if (/^-|-$/.test(part)) { + return false; + } + + return true; + }); + + return partsValid ? { success: true } : hostError; +} + +function isIP(str: string) { + const validation = z.string().ip().safeParse(str); + return validation.success; +} + +function validateHost(hostname: string, opts: URLOptions): ValidatorRes { + let host: string; + let port: number; + let portStr: string = ""; + let split: string[]; + + // extract ipv6 & port + const wrapped_ipv6 = /^\[([^\]]+)\](?::([0-9]+))?$/; + const ipv6Match = hostname.match(wrapped_ipv6); + if (ipv6Match) { + host = ipv6Match[1]; + portStr = ipv6Match[2]; + } else { + split = hostname.split(":"); + host = split.shift() ?? ""; + if (split.length) portStr = split.join(":"); + } + + if (portStr.length) { + port = parseInt(portStr, 10); + if (Number.isNaN(port)) return urlError; + if (port <= 0 || port > 65_535) return portError; + } else if (opts.requirePort) return missingPortError; + + if (!host) return hostError; + if (isIP(host)) return { success: true }; + return isFQDN(host, opts); +} + +function isURL(str: string, options?: Partial): ValidatorRes { + const opts = mergeOptions(defaultURLOptions, options); + + if (str.length === 0 || /[\s<>]/.test(str)) return urlError; + if (opts.validateLength && str.length > opts.maxLength) { + return error(`Invalid url length, max of ${opts.maxLength} expected.`); + } + + let url = str; + let split: string[]; + + split = url.split("#"); + url = split.shift() ?? ""; + + split = url.split("?"); + url = split.shift() ?? ""; + + if (url.slice(0, 2) === "//") return relProtocolError; + + // extract protocol & validate + split = url.split("://"); + if (split.length > 2) return multiProtocolError; + if (split.length > 1) { + const protocol = split.shift()?.toLowerCase() ?? ""; + if (opts.protocols.length && opts.protocols.indexOf(protocol) === -1) + return getProtocolError(opts.protocols); + if (protocol.includes(",")) return multihostProtocolError; + url = split.join("://"); + } else if (opts.requireProtocol) { + return getProtocolError(opts.protocols); + } + + split = url.split("/"); + url = split.shift() ?? ""; + if (!url.length) return urlError; + + // extract auth details & validate + split = url.split("@"); + if (split.length > 1 && !split[0]) return urlError; + if (split.length > 1) { + const auth = split.shift() ?? ""; + if (auth.split(":").length > 2) return urlError; + const [user, pass] = auth.split(":"); + if (!user && !pass) return urlError; + } + const hostname = split.join("@"); + + // validate multihost + split = hostname.split(","); + if (split.length > 1 && !opts.alllowMultihost) return multihostError; + if (split.length > 1) { + for (const host of split) { + const res = validateHost(host, opts); + if (!res.success) return res; + } + return { success: true }; + } + return validateHost(hostname, opts); +} + +const required_error = "This field is required"; + +function getBaseUrlSchema(options?: Partial) { + const urlStr = z.string({ required_error }); + const schema = urlStr.superRefine((url, ctx) => { + const valdn = isURL(url, options); + if (valdn.success) return; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: valdn.msg, + }); + }); + return schema; +} + +export function getZodSchema(fields: Provider["config"], installed: boolean) { + const portError = "Invalid port number"; + + const kvPairs = Object.entries(fields).map(([field, config]) => { + if (config.type === "form") { + const baseSchema = z.record(z.string(), z.string()).array(); + const schema = config.required + ? baseSchema.nonempty({ + message: "At least one key-value entry should be provided.", + }) + : baseSchema.optional(); + return [field, schema]; + } + + if (config.type === "file") { + const baseSchema = z + .instanceof(File, { message: "Please upload a file here." }) + .or(z.string()) + .refine( + (file) => { + if (config.file_type == undefined) return true; + if (config.file_type.length <= 1) return true; + if (typeof file === "string" && installed) return true; + return ( + typeof file !== "string" && config.file_type.includes(file.type) + ); + }, + { + message: + config.file_type && config.file_type?.split(",").length > 1 + ? `File type should be one of ${config.file_type}.` + : `File should be of type ${config.file_type}.`, + } + ); + const schema = config.required ? baseSchema : baseSchema.optional(); + return [field, schema]; + } + + if (config.type === "switch") { + const schema = config.required ? z.boolean() : z.boolean().optional(); + return [field, schema]; + } + + if (config.validation === "any_url") { + const baseSchema = getBaseUrlSchema(); + const schema = config.required ? baseSchema : baseSchema.optional(); + return [field, schema]; + } + + if (config.validation === "any_http_url") { + const baseSchema = getBaseUrlSchema({ protocols: ["http", "https"] }); + const schema = config.required ? baseSchema : baseSchema.optional(); + return [field, schema]; + } + + if (config.validation === "https_url") { + const baseSchema = getBaseUrlSchema({ + protocols: ["https"], + requireTld: true, + maxLength: 2083, + }); + const schema = config.required ? baseSchema : baseSchema.optional(); + return [field, schema]; + } + + if (config.validation === "no_scheme_url") { + const baseSchema = getBaseUrlSchema({ requireProtocol: false }); + const schema = config.required ? baseSchema : baseSchema.optional(); + return [field, schema]; + } + + if (config.validation === "multihost_url") { + const baseSchema = getBaseUrlSchema({ alllowMultihost: true }); + const schema = config.required ? baseSchema : baseSchema.optional(); + return [field, schema]; + } + + if (config.validation === "no_scheme_multihost_url") { + const baseSchema = getBaseUrlSchema({ + alllowMultihost: true, + requireProtocol: false, + }); + const schema = config.required ? baseSchema : baseSchema.optional(); + return [field, schema]; + } + + if (config.validation === "tld") { + const baseSchema = z + .string({ required_error }) + .regex(new RegExp(/\.[a-z]{2,63}$/), { + message: "Please provide a valid TLD e.g .com, .io, .dev, .net", + }); + const schema = config.required ? baseSchema : baseSchema.optional(); + return [field, schema]; + } + + if (config.validation === "port") { + const baseSchema = z + .string({ required_error }) + .pipe( + z.coerce + .number({ invalid_type_error: portError }) + .min(1, { message: portError }) + .max(65_535, { message: portError }) + ); + const schema = config.required ? baseSchema : baseSchema.optional(); + return [field, schema]; + } + return [ + field, + config.required + ? z + .string({ required_error }) + .trim() + .min(1, { message: required_error }) + : z.string().optional(), + ]; + }); + return z.object({ + provider_name: z + .string({ required_error }) + .trim() + .min(1, { message: required_error }), + ...Object.fromEntries(kvPairs), + }); +} diff --git a/keep-ui/app/(keep)/providers/provider-form-scopes.tsx b/keep-ui/app/(keep)/providers/provider-form-scopes.tsx index 8457eec0d..d5b60130e 100644 --- a/keep-ui/app/(keep)/providers/provider-form-scopes.tsx +++ b/keep-ui/app/(keep)/providers/provider-form-scopes.tsx @@ -22,29 +22,24 @@ import "./provider-form-scopes.css"; const ProviderFormScopes = ({ provider, validatedScopes, - installedProvidersMode = false, refreshLoading, - triggerRevalidateScope, + onRevalidate, }: { provider: Provider; validatedScopes: { [key: string]: string | boolean }; - installedProvidersMode?: boolean; refreshLoading: boolean; - triggerRevalidateScope: any; + onRevalidate: () => void; }) => { return ( Scopes - {installedProvidersMode && ( + {provider.installed && ( - - handleDictInputChange(configKey, value)} - error={Object.keys(inputErrors).includes(configKey)} - disabled={provider.provisioned} - /> - - ); - case "file": - return ( - <> - {renderFieldHeader()} - - { - if (e.target.files && e.target.files[0]) { - setSelectedFile(e.target.files[0].name); - } - handleInputChange(e); - }} - disabled={provider.provisioned} - /> - - ); - default: - return ( - <> - {renderFieldHeader()} - - - ); + function setApiError(error: string) { + if (error.includes("SyntaxError")) { + setFormErrors( + "Bad response from API: Check the backend logs for more details" + ); + } else if (error.includes("Failed to fetch")) { + setFormErrors( + "Failed to connect to API: Check provider settings and your internet connection" + ); + } else { + setFormErrors(error); } - }; - - const requiredConfigs = Object.entries(provider.config) - .filter(([_, config]) => config.required && !config.config_main_group) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); + } - const optionalConfigs = Object.entries(provider.config) - .filter( - ([_, config]) => - !config.required && !config.hidden && !config.config_main_group - ) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}); + async function handleUpdateClick() { + if (provider.webhook_required) callInstallWebhook(); + if (!validate()) return; + setIsLoading(true); + submit(`/providers/${provider.id}`, "PUT") + .then(() => { + setIsLoading(false); + toast.success("Updated provider successfully", { + position: "top-left", + }); + mutate(); + }) + .catch((error) => { + showErrorToast("Failed to update provider"); + handleSubmitError(error); + setIsLoading(false); + }); + } - const groupConfigsByMainGroup = (configs) => { - return Object.entries(configs).reduce((acc, [key, config]) => { - const mainGroup = config.config_main_group; - if (mainGroup) { - if (!acc[mainGroup]) { - acc[mainGroup] = {}; + async function handleConnectClick() { + if (!validate()) return; + setIsLoading(true); + onConnectChange?.(true, false); + submit(`/providers/install`) + .then(async (data) => { + console.log("Connect Result:", data); + setIsLoading(false); + onConnectChange?.(false, true); + if ( + formValues.install_webhook && + provider.can_setup_webhook && + !isLocalhost + ) { + // mutate after webhook installation + await installWebhook(data as Provider); } - acc[mainGroup][key] = config; - } - return acc; - }, {}); - }; - - const groupConfigsBySubGroup = (configs) => { - return Object.entries(configs).reduce((acc, [key, config]) => { - const subGroup = config.config_sub_group || "default"; - if (!acc[subGroup]) { - acc[subGroup] = {}; - } - acc[subGroup][key] = config; - return acc; - }, {}); - }; - - const getSubGroups = (configs) => { - return [ - ...new Set( - Object.values(configs).map((config) => config.config_sub_group) - ), - ].filter(Boolean); - }; - - const renderGroupFields = (groupName, groupConfigs) => { - const subGroups = groupConfigsBySubGroup(groupConfigs); - const subGroupNames = getSubGroups(groupConfigs); - - if (subGroupNames.length === 0) { - // If no subgroups, render fields directly - return ( - - - {groupName.charAt(0).toUpperCase() + groupName.slice(1)} - - {Object.entries(groupConfigs).map(([configKey, config]) => ( -
- {renderFormField(configKey, config)} -
- ))} -
- ); - } + mutate(); + }) + .catch((error) => { + handleSubmitError(error); + setIsLoading(false); + onConnectChange?.(false, false); + }); + } - return ( - - {groupName.charAt(0).toUpperCase() + groupName.slice(1)} - - setActiveTabsState((prev) => ({ - ...prev, - [groupName]: subGroupNames[index], - })) - } - > - - {subGroupNames.map((subGroup) => ( - - {subGroup.replace("_", " ").toUpperCase()} - - ))} - - - {subGroupNames.map((subGroup) => ( - - {Object.entries(subGroups[subGroup] || {}).map( - ([configKey, config]) => ( -
- {renderFormField(configKey, config)} -
- ) - )} -
- ))} -
-
-
- ); - }; + const installOrUpdateWebhookEnabled = provider.scopes + ?.filter((scope) => scope.mandatory_for_webhook) + .every((scope) => providerValidatedScopes[scope.name] === true); - const groupedConfigs = groupConfigsByMainGroup(provider.config); - console.log("ProviderForm component loaded"); return (
@@ -797,6 +451,7 @@ const ProviderForm = ({ {provider.provisioned && (
)} - {provider.scopes?.length > 0 && ( + {provider.scopes && provider.scopes.length > 0 && ( )}
@@ -857,6 +511,7 @@ const ProviderForm = ({ {provider.oauth2_url && !provider.installed ? ( <>
{/* Render required fields */} - {Object.entries(requiredConfigs).map(([configKey, config]) => ( -
- {renderFormField(configKey, config)} + {Object.entries(requiredConfigs).map(([field, config]) => ( +
+
))} {/* Render grouped fields */} - {Object.entries(groupedConfigs).map(([groupName, groupConfigs]) => ( - - {renderGroupFields(groupName, groupConfigs)} + {Object.entries(groupedConfigs).map(([name, fields]) => ( + + ))} @@ -915,13 +574,18 @@ const ProviderForm = ({ Provider Optional Settings - {Object.entries(optionalConfigs).map( - ([configKey, config]) => ( -
- {renderFormField(configKey, config)} -
- ) - )} + {Object.entries(optionalConfigs).map(([field, config]) => ( +
+ +
+ ))}
@@ -937,7 +601,10 @@ const ProviderForm = ({ className="mr-2.5" onChange={handleWebhookChange} checked={ - (formValues["install_webhook"] || false) && !isLocalhost + "install_webhook" in formValues && + typeof formValues["install_webhook"] === "boolean" && + formValues["install_webhook"] && + !isLocalhost } disabled={isLocalhost || provider.webhook_required} /> @@ -963,7 +630,7 @@ const ProviderForm = ({ name="pulling_enabled" className="mr-2.5" onChange={handlePullingEnabledChange} - checked={formValues["pulling_enabled"] || false} + checked={Boolean(formValues["pulling_enabled"])} />