diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 1d7f8aed8..ce6a24c28 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -19,6 +19,7 @@ import { } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; +import { useSession } from "@homarr/auth/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { revalidatePathActionAsync } from "@homarr/common/client"; @@ -62,6 +63,7 @@ export const BoardContentHeaderActions = () => { }; const AddMenu = () => { + const { data: session } = useSession(); const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal); const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); const { openModal: openAppSelectModal } = useModalAction(AppSelectModal); @@ -96,12 +98,13 @@ const AddMenu = () => { const handleSelectApp = useCallback(() => { openAppSelectModal({ - onSelect: (appId) => { + onSelect: (app) => { createItem({ kind: "app", - options: { appId }, + options: { appId: app.id }, }); }, + withCreate: session?.user.permissions.includes("app-create") ?? false, }); }, [openAppSelectModal, createItem]); 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 8f1b576c9..9dd9dbceb 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,16 +3,18 @@ import { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { Alert, Button, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core"; -import { IconInfoCircle } from "@tabler/icons-react"; -import type { z } from "zod/v4"; +import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core"; +import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react"; +import { z } from "zod/v4"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +import { useSession } from "@homarr/auth/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions"; import { useZodForm } from "@homarr/form"; -import { useConfirmModal } from "@homarr/modals"; +import { useConfirmModal, useModalAction } from "@homarr/modals"; +import { AppSelectModal } from "@homarr/modals-collection"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; import { integrationUpdateSchema } from "@homarr/validation/integration"; @@ -27,6 +29,19 @@ interface EditIntegrationForm { integration: RouterOutputs["integration"]["byId"]; } +const formSchema = integrationUpdateSchema.omit({ id: true, appId: true }).and( + z.object({ + app: z + .object({ + id: z.string(), + name: z.string(), + iconUrl: z.string(), + href: z.string().nullable(), + }) + .nullable(), + }), +); + export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { const t = useI18n(); const { openConfirmModal } = useConfirmModal(); @@ -40,7 +55,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { const hasUrlSecret = initialSecretsKinds.includes("url"); const router = useRouter(); - const form = useZodForm(integrationUpdateSchema.omit({ id: true }), { + const form = useZodForm(formSchema, { initialValues: { name: integration.name, url: integration.url, @@ -48,6 +63,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { kind, value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "", })), + app: integration.app ?? null, }, }); const { mutateAsync, isPending } = clientApi.integration.update.useMutation(); @@ -55,7 +71,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret])); - const handleSubmitAsync = async (values: FormType) => { + const handleSubmitAsync = async ({ app, ...values }: FormType) => { const url = hasUrlSecret ? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin : values.url; @@ -68,6 +84,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { kind: secret.kind, value: secret.value === "" ? null : secret.value, })), + appId: app?.id ?? null, }, { onSuccess: (data) => { @@ -102,7 +119,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { form.values.secrets.length === initialSecretsKinds.length; return ( -
void handleSubmitAsync(values))}> + await handleSubmitAsync(values))}> @@ -169,6 +186,8 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { + form.setFieldValue("app", app)} /> + {error !== null && } @@ -184,4 +203,80 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => { ); }; -type FormType = Omit, "id">; +type FormType = z.infer; + +interface IntegrationAppSelectProps { + value: FormType["app"]; + onChange: (app: FormType["app"]) => void; +} + +const IntegrationLinkApp = ({ value, onChange }: IntegrationAppSelectProps) => { + const { openModal } = useModalAction(AppSelectModal); + const t = useI18n(); + const { data: session } = useSession(); + const canCreateApps = session?.user.permissions.includes("app-create") ?? false; + + const handleChange = () => + openModal( + { + onSelect: onChange, + withCreate: canCreateApps, + }, + { + title: t("integration.page.edit.app.action.select"), + }, + ); + + if (!value) { + return ( + + ); + } + + return ( +
+ + + {/* eslint-disable-next-line @next/next/no-img-element */} + {value.name} + + + {value.name} + + {value.href !== null && ( + + {value.href} + + )} + + + + + + + +
+ ); +}; 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 5e78d549e..51de06b72 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,14 +1,29 @@ "use client"; -import { useState } from "react"; +import { startTransition, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { Alert, Button, Checkbox, Collapse, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core"; -import { IconInfoCircle } from "@tabler/icons-react"; +import { + Alert, + Button, + Checkbox, + Collapse, + Fieldset, + Group, + Loader, + SegmentedControl, + Select, + Stack, + Text, + TextInput, +} from "@mantine/core"; +import { IconCheck, IconInfoCircle } from "@tabler/icons-react"; import { z } from "zod/v4"; import { clientApi } from "@homarr/api/client"; +import { useSession } from "@homarr/auth/client"; import { revalidatePathActionAsync } from "@homarr/common/client"; +import type { Modify } from "@homarr/common/types"; import type { IntegrationKind } from "@homarr/definitions"; import { getAllSecretKindOptions, @@ -17,6 +32,7 @@ import { getIntegrationName, integrationDefs, } from "@homarr/definitions"; +import type { GetInputPropsReturnType, UseFormReturnType } from "@homarr/form"; import { useZodForm } from "@homarr/form"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; import { useI18n } from "@homarr/translation/client"; @@ -34,10 +50,11 @@ interface NewIntegrationFormProps { }; } -const formSchema = integrationCreateSchema.omit({ kind: true }).and( +const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and( z.object({ - createApp: z.boolean(), + hasApp: z.boolean(), appHref: appHrefSchema, + appId: z.string().nullable(), }), ); @@ -46,7 +63,8 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => const secretKinds = getAllSecretKindOptions(searchParams.kind); const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url")); const router = useRouter(); - const [opened, setOpened] = useState(false); + const { data: session } = useSession(); + const canCreateApps = session?.user.permissions.includes("app-create") ?? false; let url = searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? ""; if (hasUrlSecret) { @@ -62,31 +80,40 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => value: "", })), attemptSearchEngineCreation: true, - createApp: false, - appHref: "", - }, - onValuesChange(values, previous) { - if (values.createApp !== previous.createApp) { - setOpened(values.createApp); - } + hasApp: false, + appHref: url, + appId: null, }, }); - const { mutateAsync: createIntegrationAsync, isPending: isPendingIntegration } = - clientApi.integration.create.useMutation(); - const { mutateAsync: createAppAsync, isPending: isPendingApp } = clientApi.app.create.useMutation(); - const isPending = isPendingIntegration || isPendingApp; + const { mutateAsync: createIntegrationAsync, isPending } = clientApi.integration.create.useMutation(); const [error, setError] = useState(null); - const handleSubmitAsync = async (values: FormType) => { + const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => { const url = hasUrlSecret ? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin : values.url; + + const hasCustomHref = appHref !== null && appHref.trim().length >= 1; + + const app = hasApp + ? appId !== null + ? { id: appId } + : { + name: values.name, + href: hasCustomHref ? appHref : url, + iconUrl: getIconUrl(searchParams.kind), + description: null, + pingUrl: url, + } + : undefined; + await createIntegrationAsync( { kind: searchParams.kind, ...values, url, + app, }, { async onSuccess(data) { @@ -105,32 +132,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => message: t("integration.page.create.notification.success.message"), }); - if (!values.createApp) { - await revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations")); - return; - } - - const hasCustomHref = values.appHref !== null && values.appHref.trim().length >= 1; - await createAppAsync( - { - name: values.name, - href: hasCustomHref ? values.appHref : url, - iconUrl: getIconUrl(searchParams.kind), - description: null, - pingUrl: url, - }, - { - async onSettled() { - await revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations")); - }, - onError() { - showErrorNotification({ - title: t("app.page.create.notification.error.title"), - message: t("app.page.create.notification.error.message"), - }); - }, - }, - ); + await revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations")); }, onError: () => { showErrorNotification({ @@ -184,15 +186,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => /> )} - - - - - + + {canCreateApps && ( + + )} ); };