feat(integrations): allow changing secret kinds of existing integration (#4254)

This commit is contained in:
Meier Lukas
2025-10-10 19:59:23 +02:00
committed by GitHub
parent 50a23d76e3
commit 3b708c5ebd
4 changed files with 148 additions and 86 deletions

View File

@@ -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<TFormType extends FormType> {
defaultKinds?: IntegrationSecretKind[];
secretKinds: IntegrationSecretKind[][];
form: UseFormReturnType<TFormType, (values: TFormType) => TFormType>;
}
export const SecretKindsSegmentedControl = <TFormType extends FormType>({
defaultKinds,
secretKinds,
form,
}: SecretKindsSegmentedControlProps<TFormType>) => {
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 (
<SegmentedControl
fullWidth
data={secretKindGroups}
defaultValue={defaultValue}
onChange={onChange}
></SegmentedControl>
);
};

View File

@@ -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 (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack>
@@ -104,36 +112,60 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
<Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm">
{secretsKinds.map((kind, index) => (
<SecretCard
key={kind}
secret={secretsMap.get(kind) ?? { kind, value: null, updatedAt: null }}
onCancel={() =>
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 && (
<SecretKindsSegmentedControl
defaultKinds={initialSecretsKinds}
secretKinds={allSecretKinds}
form={form}
/>
)}
{!isInitialSecretKinds
? null
: form.values.secrets.map((secret, index) => (
<SecretCard
key={secret.kind}
secret={secretsMap.get(secret.kind) ?? { kind: secret.kind, value: null, updatedAt: null }}
onCancel={() =>
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);
},
});
})
}
>
<IntegrationSecretInput
label={t(`integration.secrets.kind.${kind}.newLabel`)}
key={kind}
kind={kind}
{...form.getInputProps(`secrets.${index}.value`)}
/>
</SecretCard>
))}
>
<IntegrationSecretInput
label={t(`integration.secrets.kind.${secret.kind}.newLabel`)}
key={secret.kind}
kind={secret.kind}
{...form.getInputProps(`secrets.${index}.value`)}
/>
</SecretCard>
))}
{isInitialSecretKinds
? null
: form.values.secrets.map(({ kind }, index) => (
<IntegrationSecretInput
withAsterisk
key={kind}
kind={kind}
{...form.getInputProps(`secrets.${index}.value`)}
/>
))}
{form.values.secrets.length === 0 && (
<Alert icon={<IconInfoCircle size={"1rem"} />} color={"blue"}>
<Text c={"blue"}>{t("integration.secrets.noSecretsRequired.text")}</Text>
</Alert>
)}
</Stack>
</Fieldset>

View File

@@ -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, (values: FormType) => 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 <SegmentedControl fullWidth data={secretKindGroups} onChange={onChange}></SegmentedControl>;
};
type FormType = z.infer<typeof formSchema>;

View File

@@ -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,