diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-segmented-control.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-segmented-control.tsx new file mode 100644 index 000000000..e524b3870 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-segmented-control.tsx @@ -0,0 +1,62 @@ +import { useCallback } from "react"; +import { SegmentedControl } from "@mantine/core"; + +import type { IntegrationSecretKind } from "@homarr/definitions"; +import type { UseFormReturnType } from "@homarr/form"; +import { useScopedI18n } from "@homarr/translation/client"; + +interface FormType { + secrets: { kind: IntegrationSecretKind; value: string | null }[]; +} + +interface SecretKindsSegmentedControlProps { + defaultKinds?: IntegrationSecretKind[]; + secretKinds: IntegrationSecretKind[][]; + form: UseFormReturnType TFormType>; +} + +export const SecretKindsSegmentedControl = ({ + defaultKinds, + secretKinds, + form, +}: SecretKindsSegmentedControlProps) => { + const t = useScopedI18n("integration.secrets"); + + const defaultValue = defaultKinds?.length === 0 ? "empty" : defaultKinds?.join("-"); + const secretKindGroups = secretKinds.map((kinds) => ({ + label: + kinds.length === 0 + ? t("noSecretsRequired.segmentTitle") + : kinds.map((kind) => t(`kind.${kind}.label`)).join(" & "), + value: kinds.length === 0 ? "empty" : kinds.join("-"), + })); + + const onChange = useCallback( + (value: string) => { + if (value === "empty") { + const emptyValues = [] satisfies FormType["secrets"]; + // @ts-expect-error somehow it is not able to understand that secrets is an array? + form.setFieldValue("secrets", emptyValues); + return; + } + + const kinds = value.split("-") as IntegrationSecretKind[]; + const secrets = kinds.map((kind) => ({ + kind, + value: "", + })) satisfies FormType["secrets"]; + // @ts-expect-error somehow it is not able to understand that secrets is an array? + form.setFieldValue("secrets", secrets); + }, + [form], + ); + + return ( + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx index 3aa5f3707..8f1b576c9 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/edit/[id]/_integration-edit-form.tsx @@ -3,7 +3,8 @@ import { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core"; +import { Alert, Button, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core"; +import { IconInfoCircle } from "@tabler/icons-react"; import type { z } from "zod/v4"; import type { RouterOutputs } from "@homarr/api"; @@ -18,6 +19,7 @@ import { integrationUpdateSchema } from "@homarr/validation/integration"; import { SecretCard } from "../../_components/secrets/integration-secret-card"; import { IntegrationSecretInput } from "../../_components/secrets/integration-secret-inputs"; +import { SecretKindsSegmentedControl } from "../../_components/secrets/integration-secret-segmented-control"; import { IntegrationTestConnectionError } from "../../_components/test-connection/integration-test-connection-error"; import type { AnyMappedTestConnectionError } from "../../_components/test-connection/types"; @@ -28,19 +30,21 @@ interface EditIntegrationForm { export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { const t = useI18n(); const { openConfirmModal } = useConfirmModal(); - const secretsKinds = + const allSecretKinds = getAllSecretKindOptions(integration.kind); + + const initialSecretsKinds = getAllSecretKindOptions(integration.kind).find((secretKinds) => integration.secrets.every((secret) => secretKinds.includes(secret.kind)), ) ?? getDefaultSecretKinds(integration.kind); - const hasUrlSecret = secretsKinds.includes("url"); + const hasUrlSecret = initialSecretsKinds.includes("url"); const router = useRouter(); const form = useZodForm(integrationUpdateSchema.omit({ id: true }), { initialValues: { name: integration.name, url: integration.url, - secrets: secretsKinds.map((kind) => ({ + secrets: initialSecretsKinds.map((kind) => ({ kind, value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "", })), @@ -93,6 +97,10 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { ); }; + const isInitialSecretKinds = + initialSecretsKinds.every((kind) => form.values.secrets.some((secret) => secret.kind === kind)) && + form.values.secrets.length === initialSecretsKinds.length; + return (
void handleSubmitAsync(values))}> @@ -104,36 +112,60 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
- {secretsKinds.map((kind, index) => ( - - new Promise((resolve) => { - // When nothing changed, just close the secret card - if ((form.values.secrets[index]?.value ?? "") === (secretsMap.get(kind)?.value ?? "")) { - return resolve(true); + {allSecretKinds.length > 1 && ( + + )} + {!isInitialSecretKinds + ? null + : form.values.secrets.map((secret, index) => ( + + new Promise((resolve) => { + // When nothing changed, just close the secret card + if ((secret.value ?? "") === (secretsMap.get(secret.kind)?.value ?? "")) { + return resolve(true); + } + openConfirmModal({ + title: t("integration.secrets.reset.title"), + children: t("integration.secrets.reset.message"), + onCancel: () => resolve(false), + onConfirm: () => { + form.setFieldValue(`secrets.${index}.value`, secretsMap.get(secret.kind)?.value ?? ""); + resolve(true); + }, + }); + }) } - openConfirmModal({ - title: t("integration.secrets.reset.title"), - children: t("integration.secrets.reset.message"), - onCancel: () => resolve(false), - onConfirm: () => { - form.setFieldValue(`secrets.${index}.value`, secretsMap.get(kind)?.value ?? ""); - resolve(true); - }, - }); - }) - } - > - - - ))} + > + + + ))} + {isInitialSecretKinds + ? null + : form.values.secrets.map(({ kind }, index) => ( + + ))} + {form.values.secrets.length === 0 && ( + } color={"blue"}> + {t("integration.secrets.noSecretsRequired.text")} + + )}
diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index 078e1d3e1..5e78d549e 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -1,26 +1,15 @@ "use client"; -import { useCallback, useState } from "react"; +import { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { - Alert, - Button, - Checkbox, - Collapse, - Fieldset, - Group, - SegmentedControl, - Stack, - Text, - TextInput, -} from "@mantine/core"; +import { Alert, Button, Checkbox, Collapse, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core"; import { IconInfoCircle } from "@tabler/icons-react"; import { z } from "zod/v4"; import { clientApi } from "@homarr/api/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; -import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions"; +import type { IntegrationKind } from "@homarr/definitions"; import { getAllSecretKindOptions, getIconUrl, @@ -28,14 +17,14 @@ import { getIntegrationName, integrationDefs, } from "@homarr/definitions"; -import type { UseFormReturnType } from "@homarr/form"; import { useZodForm } from "@homarr/form"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import { useI18n } from "@homarr/translation/client"; import { appHrefSchema } from "@homarr/validation/app"; import { integrationCreateSchema } from "@homarr/validation/integration"; import { IntegrationSecretInput } from "../_components/secrets/integration-secret-inputs"; +import { SecretKindsSegmentedControl } from "../_components/secrets/integration-secret-segmented-control"; import { IntegrationTestConnectionError } from "../_components/test-connection/integration-test-connection-error"; import type { AnyMappedTestConnectionError } from "../_components/test-connection/types"; @@ -218,40 +207,4 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => ); }; -interface SecretKindsSegmentedControlProps { - secretKinds: IntegrationSecretKind[][]; - form: UseFormReturnType FormType>; -} - -const SecretKindsSegmentedControl = ({ secretKinds, form }: SecretKindsSegmentedControlProps) => { - const t = useScopedI18n("integration.secrets"); - - const secretKindGroups = secretKinds.map((kinds) => ({ - label: - kinds.length === 0 - ? t("noSecretsRequired.segmentTitle") - : kinds.map((kind) => t(`kind.${kind}.label`)).join(" & "), - value: kinds.length === 0 ? "empty" : kinds.join("-"), - })); - - const onChange = useCallback( - (value: string) => { - if (value === "empty") { - form.setFieldValue("secrets", []); - return; - } - - const kinds = value.split("-") as IntegrationSecretKind[]; - const secrets = kinds.map((kind) => ({ - kind, - value: "", - })); - form.setFieldValue("secrets", secrets); - }, - [form], - ); - - return ; -}; - type FormType = z.infer; diff --git a/packages/api/src/router/integration/integration-router.ts b/packages/api/src/router/integration/integration-router.ts index 4546eeb16..11361f1fe 100644 --- a/packages/api/src/router/integration/integration-router.ts +++ b/packages/api/src/router/integration/integration-router.ts @@ -4,7 +4,7 @@ import { z } from "zod/v4"; import { createId, objectEntries } from "@homarr/common"; import { decryptSecret, encryptSecret } from "@homarr/common/server"; import type { Database } from "@homarr/db"; -import { and, asc, eq, handleTransactionsAsync, inArray, like } from "@homarr/db"; +import { and, asc, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db"; import { groupMembers, groupPermissions, @@ -386,6 +386,21 @@ export const integrationRouter = createTRPCRouter({ } } + const removedSecrets = integration.secrets.filter( + (dbSecret) => !input.secrets.some((secret) => dbSecret.kind === secret.kind), + ); + if (removedSecrets.length >= 1) { + await ctx.db + .delete(integrationSecrets) + .where( + or( + ...removedSecrets.map((secret) => + and(eq(integrationSecrets.integrationId, input.id), eq(integrationSecrets.kind, secret.kind)), + ), + ), + ); + } + logger.info("Updated integration", { id: input.id, name: input.name,