mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 16:30:57 +01:00
feat(integrations): allow changing secret kinds of existing integration (#4254)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user