feat(integrations): add app linking (#4338)
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { useRequiredBoard } from "@homarr/boards/context";
|
import { useRequiredBoard } from "@homarr/boards/context";
|
||||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
@@ -62,6 +63,7 @@ export const BoardContentHeaderActions = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AddMenu = () => {
|
const AddMenu = () => {
|
||||||
|
const { data: session } = useSession();
|
||||||
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
|
||||||
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
|
||||||
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
|
||||||
@@ -96,12 +98,13 @@ const AddMenu = () => {
|
|||||||
|
|
||||||
const handleSelectApp = useCallback(() => {
|
const handleSelectApp = useCallback(() => {
|
||||||
openAppSelectModal({
|
openAppSelectModal({
|
||||||
onSelect: (appId) => {
|
onSelect: (app) => {
|
||||||
createItem({
|
createItem({
|
||||||
kind: "app",
|
kind: "app",
|
||||||
options: { appId },
|
options: { appId: app.id },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
withCreate: session?.user.permissions.includes("app-create") ?? false,
|
||||||
});
|
});
|
||||||
}, [openAppSelectModal, createItem]);
|
}, [openAppSelectModal, createItem]);
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,18 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Alert, Button, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
|
import { Alert, Anchor, Button, ButtonGroup, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
import { IconInfoCircle, IconPencil, IconPlus, IconUnlink } from "@tabler/icons-react";
|
||||||
import type { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
|
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
|
||||||
import { useZodForm } from "@homarr/form";
|
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 { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
import { integrationUpdateSchema } from "@homarr/validation/integration";
|
import { integrationUpdateSchema } from "@homarr/validation/integration";
|
||||||
@@ -27,6 +29,19 @@ interface EditIntegrationForm {
|
|||||||
integration: RouterOutputs["integration"]["byId"];
|
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) => {
|
export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
@@ -40,7 +55,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
const hasUrlSecret = initialSecretsKinds.includes("url");
|
const hasUrlSecret = initialSecretsKinds.includes("url");
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const form = useZodForm(integrationUpdateSchema.omit({ id: true }), {
|
const form = useZodForm(formSchema, {
|
||||||
initialValues: {
|
initialValues: {
|
||||||
name: integration.name,
|
name: integration.name,
|
||||||
url: integration.url,
|
url: integration.url,
|
||||||
@@ -48,6 +63,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
kind,
|
kind,
|
||||||
value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
|
value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
|
||||||
})),
|
})),
|
||||||
|
app: integration.app ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
|
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 secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
|
||||||
|
|
||||||
const handleSubmitAsync = async (values: FormType) => {
|
const handleSubmitAsync = async ({ app, ...values }: FormType) => {
|
||||||
const url = hasUrlSecret
|
const url = hasUrlSecret
|
||||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||||
: values.url;
|
: values.url;
|
||||||
@@ -68,6 +84,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
kind: secret.kind,
|
kind: secret.kind,
|
||||||
value: secret.value === "" ? null : secret.value,
|
value: secret.value === "" ? null : secret.value,
|
||||||
})),
|
})),
|
||||||
|
appId: app?.id ?? null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
@@ -102,7 +119,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
form.values.secrets.length === initialSecretsKinds.length;
|
form.values.secrets.length === initialSecretsKinds.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
<form onSubmit={form.onSubmit(async (values) => await handleSubmitAsync(values))}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
|
||||||
|
|
||||||
@@ -169,6 +186,8 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Fieldset>
|
</Fieldset>
|
||||||
|
|
||||||
|
<IntegrationLinkApp value={form.values.app} onChange={(app) => form.setFieldValue("app", app)} />
|
||||||
|
|
||||||
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
|
{error !== null && <IntegrationTestConnectionError error={error} url={form.values.url} />}
|
||||||
|
|
||||||
<Group justify="end" align="center">
|
<Group justify="end" align="center">
|
||||||
@@ -184,4 +203,80 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type FormType = Omit<z.infer<typeof integrationUpdateSchema>, "id">;
|
type FormType = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<IconPlus size={16} stroke={1.5} />}
|
||||||
|
fullWidth
|
||||||
|
onClick={handleChange}
|
||||||
|
>
|
||||||
|
{t("integration.page.edit.app.action.add")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fieldset legend={t("integration.field.app.sectionTitle")}>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Group gap="sm">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img src={value.iconUrl} alt={value.name} width={32} height={32} />
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm" fw="bold">
|
||||||
|
{value.name}
|
||||||
|
</Text>
|
||||||
|
{value.href !== null && (
|
||||||
|
<Anchor href={value.href} target="_blank" rel="noopener noreferrer" size="sm">
|
||||||
|
{value.href}
|
||||||
|
</Anchor>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Group>
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<IconUnlink size={16} stroke={1.5} />}
|
||||||
|
onClick={() => onChange(null)}
|
||||||
|
>
|
||||||
|
{t("integration.page.edit.app.action.remove")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
leftSection={<IconPencil size={16} stroke={1.5} />}
|
||||||
|
onClick={handleChange}
|
||||||
|
>
|
||||||
|
{t("common.action.change")}
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</Group>
|
||||||
|
</Fieldset>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,14 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { startTransition, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Alert, Button, Checkbox, Collapse, Fieldset, Group, Stack, Text, TextInput } from "@mantine/core";
|
import {
|
||||||
import { IconInfoCircle } from "@tabler/icons-react";
|
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 { z } from "zod/v4";
|
||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||||
|
import type { Modify } from "@homarr/common/types";
|
||||||
import type { IntegrationKind } from "@homarr/definitions";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import {
|
import {
|
||||||
getAllSecretKindOptions,
|
getAllSecretKindOptions,
|
||||||
@@ -17,6 +32,7 @@ import {
|
|||||||
getIntegrationName,
|
getIntegrationName,
|
||||||
integrationDefs,
|
integrationDefs,
|
||||||
} from "@homarr/definitions";
|
} from "@homarr/definitions";
|
||||||
|
import type { GetInputPropsReturnType, UseFormReturnType } from "@homarr/form";
|
||||||
import { useZodForm } from "@homarr/form";
|
import { useZodForm } from "@homarr/form";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
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({
|
z.object({
|
||||||
createApp: z.boolean(),
|
hasApp: z.boolean(),
|
||||||
appHref: appHrefSchema,
|
appHref: appHrefSchema,
|
||||||
|
appId: z.string().nullable(),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -46,7 +63,8 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
const secretKinds = getAllSecretKindOptions(searchParams.kind);
|
const secretKinds = getAllSecretKindOptions(searchParams.kind);
|
||||||
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
|
const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url"));
|
||||||
const router = useRouter();
|
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) ?? "";
|
let url = searchParams.url ?? getIntegrationDefaultUrl(searchParams.kind) ?? "";
|
||||||
if (hasUrlSecret) {
|
if (hasUrlSecret) {
|
||||||
@@ -62,31 +80,40 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
value: "",
|
value: "",
|
||||||
})),
|
})),
|
||||||
attemptSearchEngineCreation: true,
|
attemptSearchEngineCreation: true,
|
||||||
createApp: false,
|
hasApp: false,
|
||||||
appHref: "",
|
appHref: url,
|
||||||
},
|
appId: null,
|
||||||
onValuesChange(values, previous) {
|
|
||||||
if (values.createApp !== previous.createApp) {
|
|
||||||
setOpened(values.createApp);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutateAsync: createIntegrationAsync, isPending: isPendingIntegration } =
|
const { mutateAsync: createIntegrationAsync, isPending } = clientApi.integration.create.useMutation();
|
||||||
clientApi.integration.create.useMutation();
|
|
||||||
const { mutateAsync: createAppAsync, isPending: isPendingApp } = clientApi.app.create.useMutation();
|
|
||||||
const isPending = isPendingIntegration || isPendingApp;
|
|
||||||
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
|
const [error, setError] = useState<null | AnyMappedTestConnectionError>(null);
|
||||||
|
|
||||||
const handleSubmitAsync = async (values: FormType) => {
|
const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => {
|
||||||
const url = hasUrlSecret
|
const url = hasUrlSecret
|
||||||
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
? new URL(values.secrets.find((secret) => secret.kind === "url")?.value ?? values.url).origin
|
||||||
: values.url;
|
: 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(
|
await createIntegrationAsync(
|
||||||
{
|
{
|
||||||
kind: searchParams.kind,
|
kind: searchParams.kind,
|
||||||
...values,
|
...values,
|
||||||
url,
|
url,
|
||||||
|
app,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
async onSuccess(data) {
|
async onSuccess(data) {
|
||||||
@@ -105,32 +132,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
message: t("integration.page.create.notification.success.message"),
|
message: t("integration.page.create.notification.success.message"),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!values.createApp) {
|
await revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
|
||||||
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"),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
showErrorNotification({
|
showErrorNotification({
|
||||||
@@ -184,15 +186,7 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Checkbox
|
<AppForm form={form} canCreateApps={canCreateApps} />
|
||||||
{...form.getInputProps("createApp", { type: "checkbox" })}
|
|
||||||
label={t("integration.field.createApp.label")}
|
|
||||||
description={t("integration.field.createApp.description")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Collapse in={opened}>
|
|
||||||
<TextInput placeholder={t("integration.field.appHref.placeholder")} {...form.getInputProps("appHref")} />
|
|
||||||
</Collapse>
|
|
||||||
|
|
||||||
<Group justify="end" align="center">
|
<Group justify="end" align="center">
|
||||||
<Button variant="default" component={Link} href="/manage/integrations">
|
<Button variant="default" component={Link} href="/manage/integrations">
|
||||||
@@ -208,3 +202,114 @@ export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
type FormType = z.infer<typeof formSchema>;
|
type FormType = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const AppForm = ({ form, canCreateApps }: { form: UseFormReturnType<FormType>; canCreateApps: boolean }) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const checkboxInputProps = form.getInputProps("hasApp", { type: "checkbox" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Checkbox
|
||||||
|
{...checkboxInputProps}
|
||||||
|
onChange={(event) => {
|
||||||
|
startTransition(() => {
|
||||||
|
form.setFieldValue("appHref", event.currentTarget.checked ? form.values.url : null);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||||
|
checkboxInputProps.onChange(event);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
label={t("integration.field.createApp.label")}
|
||||||
|
description={t("integration.field.createApp.description")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Collapse in={form.values.hasApp}>
|
||||||
|
<Fieldset legend={t("integration.field.app.sectionTitle")}>
|
||||||
|
<Stack gap="sm">
|
||||||
|
{canCreateApps && (
|
||||||
|
<SegmentedControl
|
||||||
|
data={(["new", "existing"] as const).map((value) => ({
|
||||||
|
value,
|
||||||
|
label: t(`integration.page.create.app.option.${value}.title`),
|
||||||
|
}))}
|
||||||
|
value={form.values.appHref === null ? "existing" : "new"}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value === "existing") {
|
||||||
|
form.setFieldValue("appId", null);
|
||||||
|
form.setFieldValue("appHref", null);
|
||||||
|
} else {
|
||||||
|
form.setFieldValue("appId", null);
|
||||||
|
form.setFieldValue("appHref", form.values.url);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{typeof form.values.appHref === "string" && canCreateApps ? (
|
||||||
|
<TextInput
|
||||||
|
placeholder={t("integration.field.appHref.placeholder")}
|
||||||
|
withAsterisk
|
||||||
|
label={t("integration.page.create.app.option.new.url.label")}
|
||||||
|
description={t("integration.page.create.app.option.new.url.description")}
|
||||||
|
{...form.getInputProps("appHref")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IntegrationAppSelect {...form.getInputProps("appId")} />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Fieldset>
|
||||||
|
</Collapse>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type IntegrationAppSelectProps = Modify<
|
||||||
|
GetInputPropsReturnType,
|
||||||
|
{
|
||||||
|
value?: string | null;
|
||||||
|
onChange: (value: string | null) => void;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
const IntegrationAppSelect = ({ value, ...props }: IntegrationAppSelectProps) => {
|
||||||
|
const { data, isPending } = clientApi.app.selectable.useQuery();
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
const appMap = new Map(data?.map((app) => [app.id, app] as const));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
withAsterisk
|
||||||
|
label={t("integration.page.create.app.option.existing.label")}
|
||||||
|
searchable
|
||||||
|
clearable
|
||||||
|
leftSection={
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
value ? <img width={20} height={20} src={appMap.get(value)?.iconUrl} alt={appMap.get(value)?.name} /> : null
|
||||||
|
}
|
||||||
|
renderOption={({ option, checked }) => (
|
||||||
|
<Group flex="1" gap="xs">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img width={20} height={20} src={appMap.get(option.value)?.iconUrl} alt={option.label} />
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text>{option.label}</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{appMap.get(option.value)?.href}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
{checked && (
|
||||||
|
<IconCheck
|
||||||
|
style={{ marginInlineStart: "auto" }}
|
||||||
|
stroke={1.5}
|
||||||
|
color="currentColor"
|
||||||
|
opacity={0.6}
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
data={data?.map((app) => ({ value: app.id, label: app.name }))}
|
||||||
|
rightSection={isPending ? <Loader size="sm" /> : null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
|
|||||||
const integration = await ctx.db.query.integrations.findFirst({
|
const integration = await ctx.db.query.integrations.findFirst({
|
||||||
where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)),
|
where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)),
|
||||||
with: {
|
with: {
|
||||||
|
app: true,
|
||||||
secrets: true,
|
secrets: true,
|
||||||
groupPermissions: true,
|
groupPermissions: true,
|
||||||
userPermissions: true,
|
userPermissions: true,
|
||||||
@@ -65,6 +66,7 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
|
|||||||
ctx: {
|
ctx: {
|
||||||
integration: {
|
integration: {
|
||||||
...rest,
|
...rest,
|
||||||
|
externalUrl: rest.app?.href ?? null,
|
||||||
kind: kind as TKind,
|
kind: kind as TKind,
|
||||||
decryptedSecrets: secrets.map((secret) => ({
|
decryptedSecrets: secrets.map((secret) => ({
|
||||||
...secret,
|
...secret,
|
||||||
@@ -96,6 +98,7 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
|
|||||||
? await ctx.db.query.integrations.findMany({
|
? await ctx.db.query.integrations.findMany({
|
||||||
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
|
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
|
||||||
with: {
|
with: {
|
||||||
|
app: true,
|
||||||
secrets: true,
|
secrets: true,
|
||||||
items: {
|
items: {
|
||||||
with: {
|
with: {
|
||||||
@@ -125,6 +128,7 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
|
|||||||
integrations: dbIntegrations.map(
|
integrations: dbIntegrations.map(
|
||||||
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
|
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
|
||||||
...rest,
|
...rest,
|
||||||
|
externalUrl: rest.app?.href ?? null,
|
||||||
kind: kind as TKind,
|
kind: kind as TKind,
|
||||||
decryptedSecrets: secrets.map((secret) => ({
|
decryptedSecrets: secrets.map((secret) => ({
|
||||||
...secret,
|
...secret,
|
||||||
|
|||||||
@@ -118,20 +118,22 @@ export const appRouter = createTRPCRouter({
|
|||||||
create: permissionRequiredProcedure
|
create: permissionRequiredProcedure
|
||||||
.requiresPermission("app-create")
|
.requiresPermission("app-create")
|
||||||
.input(appManageSchema)
|
.input(appManageSchema)
|
||||||
.output(z.object({ appId: z.string() }))
|
.output(z.object({ appId: z.string() }).and(selectAppSchema))
|
||||||
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
|
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const id = createId();
|
const id = createId();
|
||||||
await ctx.db.insert(apps).values({
|
const insertValues = {
|
||||||
id,
|
id,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
description: input.description,
|
description: input.description,
|
||||||
iconUrl: input.iconUrl,
|
iconUrl: input.iconUrl,
|
||||||
href: input.href,
|
href: input.href,
|
||||||
pingUrl: input.pingUrl === "" ? null : input.pingUrl,
|
pingUrl: input.pingUrl === "" ? null : input.pingUrl,
|
||||||
});
|
};
|
||||||
|
await ctx.db.insert(apps).values(insertValues);
|
||||||
|
|
||||||
return { appId: id };
|
// TODO: breaking change necessary for removing appId property
|
||||||
|
return { appId: id, ...insertValues };
|
||||||
}),
|
}),
|
||||||
createMany: permissionRequiredProcedure
|
createMany: permissionRequiredProcedure
|
||||||
.requiresPermission("app-create")
|
.requiresPermission("app-create")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
|||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { and, asc, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
|
import { and, asc, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
|
||||||
import {
|
import {
|
||||||
|
apps,
|
||||||
groupMembers,
|
groupMembers,
|
||||||
groupPermissions,
|
groupPermissions,
|
||||||
integrationGroupPermissions,
|
integrationGroupPermissions,
|
||||||
@@ -212,6 +213,14 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
app: {
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
iconUrl: true,
|
||||||
|
href: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -233,6 +242,7 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
value: integrationSecretKindObject[secret.kind].isPublic ? decryptSecret(secret.value) : null,
|
value: integrationSecretKindObject[secret.kind].isPublic ? decryptSecret(secret.value) : null,
|
||||||
updatedAt: secret.updatedAt,
|
updatedAt: secret.updatedAt,
|
||||||
})),
|
})),
|
||||||
|
app: integration.app,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
create: permissionRequiredProcedure
|
create: permissionRequiredProcedure
|
||||||
@@ -245,6 +255,13 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
url: input.url,
|
url: input.url,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (input.app && "name" in input.app && !ctx.session.user.permissions.includes("app-create")) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Permission denied",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const result = await testConnectionAsync({
|
const result = await testConnectionAsync({
|
||||||
id: "new",
|
id: "new",
|
||||||
name: input.name,
|
name: input.name,
|
||||||
@@ -267,12 +284,15 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const appId = await createAppIfNecessaryAsync(ctx.db, input.app);
|
||||||
|
|
||||||
const integrationId = createId();
|
const integrationId = createId();
|
||||||
await ctx.db.insert(integrations).values({
|
await ctx.db.insert(integrations).values({
|
||||||
id: integrationId,
|
id: integrationId,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
url: input.url,
|
url: input.url,
|
||||||
kind: input.kind,
|
kind: input.kind,
|
||||||
|
appId,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (input.secrets.length >= 1) {
|
if (input.secrets.length >= 1) {
|
||||||
@@ -358,6 +378,7 @@ export const integrationRouter = createTRPCRouter({
|
|||||||
.set({
|
.set({
|
||||||
name: input.name,
|
name: input.name,
|
||||||
url: input.url,
|
url: input.url,
|
||||||
|
appId: input.appId,
|
||||||
})
|
})
|
||||||
.where(eq(integrations.id, input.id));
|
.where(eq(integrations.id, input.id));
|
||||||
|
|
||||||
@@ -652,3 +673,30 @@ const addSecretAsync = async (db: Database, input: AddSecretInput) => {
|
|||||||
integrationId: input.integrationId,
|
integrationId: input.integrationId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createAppIfNecessaryAsync = async (db: Database, app: z.infer<typeof integrationCreateSchema>["app"]) => {
|
||||||
|
if (!app) return null;
|
||||||
|
if ("id" in app) return app.id;
|
||||||
|
|
||||||
|
logger.info("Creating app", {
|
||||||
|
name: app.name,
|
||||||
|
url: app.href,
|
||||||
|
});
|
||||||
|
const appId = createId();
|
||||||
|
await db.insert(apps).values({
|
||||||
|
id: appId,
|
||||||
|
name: app.name,
|
||||||
|
description: app.description,
|
||||||
|
iconUrl: app.iconUrl,
|
||||||
|
href: app.href,
|
||||||
|
pingUrl: app.pingUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("Created app", {
|
||||||
|
id: appId,
|
||||||
|
name: app.name,
|
||||||
|
url: app.href,
|
||||||
|
});
|
||||||
|
|
||||||
|
return appId;
|
||||||
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getAllSecretKindOptions } from "@homarr/definitions";
|
|||||||
import { createIntegrationAsync } from "@homarr/integrations";
|
import { createIntegrationAsync } from "@homarr/integrations";
|
||||||
import { logger } from "@homarr/log";
|
import { logger } from "@homarr/log";
|
||||||
|
|
||||||
type FormIntegration = Integration & {
|
type FormIntegration = Omit<Integration, "appId"> & {
|
||||||
secrets: {
|
secrets: {
|
||||||
kind: IntegrationSecretKind;
|
kind: IntegrationSecretKind;
|
||||||
value: string | null;
|
value: string | null;
|
||||||
@@ -75,6 +75,7 @@ export const testConnectionAsync = async (
|
|||||||
const integrationInstance = await createIntegrationAsync({
|
const integrationInstance = await createIntegrationAsync({
|
||||||
...baseIntegration,
|
...baseIntegration,
|
||||||
decryptedSecrets,
|
decryptedSecrets,
|
||||||
|
externalUrl: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await integrationInstance.testConnectionAsync();
|
const result = await integrationInstance.testConnectionAsync();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { describe, expect, test, vi } from "vitest";
|
|||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
import { createId } from "@homarr/common";
|
import { createId } from "@homarr/common";
|
||||||
import { encryptSecret } from "@homarr/common/server";
|
import { encryptSecret } from "@homarr/common/server";
|
||||||
import { integrations, integrationSecrets } from "@homarr/db/schema";
|
import { apps, integrations, integrationSecrets } from "@homarr/db/schema";
|
||||||
import { createDb } from "@homarr/db/test";
|
import { createDb } from "@homarr/db/test";
|
||||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||||
|
|
||||||
@@ -251,6 +251,102 @@ describe("create should create a new integration", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("with create integration access should create a new integration with new linked app", async () => {
|
||||||
|
const db = createDb();
|
||||||
|
const caller = integrationRouter.createCaller({
|
||||||
|
db,
|
||||||
|
deviceType: undefined,
|
||||||
|
session: defaultSessionWithPermissions(["integration-create", "app-create"]),
|
||||||
|
});
|
||||||
|
const input = {
|
||||||
|
name: "Jellyfin",
|
||||||
|
kind: "jellyfin" as const,
|
||||||
|
url: "http://jellyfin.local",
|
||||||
|
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
|
||||||
|
attemptSearchEngineCreation: false,
|
||||||
|
app: {
|
||||||
|
name: "Jellyfin",
|
||||||
|
description: null,
|
||||||
|
pingUrl: "http://jellyfin.local",
|
||||||
|
href: "https://jellyfin.home",
|
||||||
|
iconUrl: "logo.png",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const fakeNow = new Date("2023-07-01T00:00:00Z");
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(fakeNow);
|
||||||
|
await caller.create(input);
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const dbIntegration = await db.query.integrations.findFirst({
|
||||||
|
with: {
|
||||||
|
app: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const dbSecret = await db.query.integrationSecrets.findFirst();
|
||||||
|
expect(dbIntegration).toBeDefined();
|
||||||
|
expect(dbIntegration!.name).toBe(input.name);
|
||||||
|
expect(dbIntegration!.kind).toBe(input.kind);
|
||||||
|
expect(dbIntegration!.url).toBe(input.url);
|
||||||
|
expect(dbIntegration!.app!.name).toBe(input.app.name);
|
||||||
|
expect(dbIntegration!.app!.pingUrl).toBe(input.app.pingUrl);
|
||||||
|
expect(dbIntegration!.app!.href).toBe(input.app.href);
|
||||||
|
expect(dbIntegration!.app!.iconUrl).toBe(input.app.iconUrl);
|
||||||
|
|
||||||
|
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
|
||||||
|
expect(dbSecret).toBeDefined();
|
||||||
|
expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
|
||||||
|
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
|
||||||
|
expect(dbSecret!.updatedAt).toEqual(fakeNow);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with create integration access should create a new integration with existing linked app", async () => {
|
||||||
|
const db = createDb();
|
||||||
|
const appId = createId();
|
||||||
|
await db.insert(apps).values({
|
||||||
|
id: appId,
|
||||||
|
name: "Existing Jellyfin",
|
||||||
|
iconUrl: "logo.png",
|
||||||
|
});
|
||||||
|
|
||||||
|
const caller = integrationRouter.createCaller({
|
||||||
|
db,
|
||||||
|
deviceType: undefined,
|
||||||
|
session: defaultSessionWithPermissions(["integration-create"]),
|
||||||
|
});
|
||||||
|
const input = {
|
||||||
|
name: "Jellyfin",
|
||||||
|
kind: "jellyfin" as const,
|
||||||
|
url: "http://jellyfin.local",
|
||||||
|
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
|
||||||
|
attemptSearchEngineCreation: false,
|
||||||
|
app: {
|
||||||
|
id: appId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const fakeNow = new Date("2023-07-01T00:00:00Z");
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(fakeNow);
|
||||||
|
await caller.create(input);
|
||||||
|
vi.useRealTimers();
|
||||||
|
|
||||||
|
const dbIntegration = await db.query.integrations.findFirst();
|
||||||
|
const dbSecret = await db.query.integrationSecrets.findFirst();
|
||||||
|
expect(dbIntegration).toBeDefined();
|
||||||
|
expect(dbIntegration!.name).toBe(input.name);
|
||||||
|
expect(dbIntegration!.kind).toBe(input.kind);
|
||||||
|
expect(dbIntegration!.url).toBe(input.url);
|
||||||
|
expect(dbIntegration!.appId).toBe(appId);
|
||||||
|
|
||||||
|
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
|
||||||
|
expect(dbSecret).toBeDefined();
|
||||||
|
expect(dbSecret!.kind).toBe(input.secrets[0]!.kind);
|
||||||
|
expect(dbSecret!.value).toMatch(/^[a-f0-9]+.[a-f0-9]+$/);
|
||||||
|
expect(dbSecret!.updatedAt).toEqual(fakeNow);
|
||||||
|
});
|
||||||
|
|
||||||
test("without create integration access should throw permission error", async () => {
|
test("without create integration access should throw permission error", async () => {
|
||||||
// Arrange
|
// Arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
@@ -273,6 +369,36 @@ describe("create should create a new integration", () => {
|
|||||||
// Assert
|
// Assert
|
||||||
await expect(actAsync()).rejects.toThrow("Permission denied");
|
await expect(actAsync()).rejects.toThrow("Permission denied");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("without create app access should throw permission error with new linked app", async () => {
|
||||||
|
// Arrange
|
||||||
|
const db = createDb();
|
||||||
|
const caller = integrationRouter.createCaller({
|
||||||
|
db,
|
||||||
|
deviceType: undefined,
|
||||||
|
session: defaultSessionWithPermissions(["integration-create"]),
|
||||||
|
});
|
||||||
|
const input = {
|
||||||
|
name: "Jellyfin",
|
||||||
|
kind: "jellyfin" as const,
|
||||||
|
url: "http://jellyfin.local",
|
||||||
|
secrets: [{ kind: "apiKey" as const, value: "1234567890" }],
|
||||||
|
attemptSearchEngineCreation: false,
|
||||||
|
app: {
|
||||||
|
name: "Jellyfin",
|
||||||
|
description: null,
|
||||||
|
href: "https://jellyfin.home",
|
||||||
|
iconUrl: "logo.png",
|
||||||
|
pingUrl: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const actAsync = async () => await caller.create(input);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expect(actAsync()).rejects.toThrow("Permission denied");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("update should update an integration", () => {
|
describe("update should update an integration", () => {
|
||||||
@@ -285,6 +411,7 @@ describe("update should update an integration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const lastWeek = new Date("2023-06-24T00:00:00Z");
|
const lastWeek = new Date("2023-06-24T00:00:00Z");
|
||||||
|
const appId = createId();
|
||||||
const integrationId = createId();
|
const integrationId = createId();
|
||||||
const toInsert = {
|
const toInsert = {
|
||||||
id: integrationId,
|
id: integrationId,
|
||||||
@@ -293,6 +420,11 @@ describe("update should update an integration", () => {
|
|||||||
url: "http://hole.local",
|
url: "http://hole.local",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await db.insert(apps).values({
|
||||||
|
id: appId,
|
||||||
|
name: "Previous",
|
||||||
|
iconUrl: "logo.png",
|
||||||
|
});
|
||||||
await db.insert(integrations).values(toInsert);
|
await db.insert(integrations).values(toInsert);
|
||||||
|
|
||||||
const usernameToInsert = {
|
const usernameToInsert = {
|
||||||
@@ -320,6 +452,7 @@ describe("update should update an integration", () => {
|
|||||||
{ kind: "password" as const, value: null },
|
{ kind: "password" as const, value: null },
|
||||||
{ kind: "apiKey" as const, value: "1234567890" },
|
{ kind: "apiKey" as const, value: "1234567890" },
|
||||||
],
|
],
|
||||||
|
appId,
|
||||||
};
|
};
|
||||||
|
|
||||||
const fakeNow = new Date("2023-07-01T00:00:00Z");
|
const fakeNow = new Date("2023-07-01T00:00:00Z");
|
||||||
@@ -335,6 +468,7 @@ describe("update should update an integration", () => {
|
|||||||
expect(dbIntegration!.name).toBe(input.name);
|
expect(dbIntegration!.name).toBe(input.name);
|
||||||
expect(dbIntegration!.kind).toBe(input.kind);
|
expect(dbIntegration!.kind).toBe(input.kind);
|
||||||
expect(dbIntegration!.url).toBe(input.url);
|
expect(dbIntegration!.url).toBe(input.url);
|
||||||
|
expect(dbIntegration!.appId).toBe(appId);
|
||||||
|
|
||||||
expect(dbSecrets.length).toBe(3);
|
expect(dbSecrets.length).toBe(3);
|
||||||
const username = expectToBeDefined(dbSecrets.find((secret) => secret.kind === "username"));
|
const username = expectToBeDefined(dbSecrets.find((secret) => secret.kind === "username"));
|
||||||
@@ -365,6 +499,7 @@ describe("update should update an integration", () => {
|
|||||||
name: "Pi Hole",
|
name: "Pi Hole",
|
||||||
url: "http://hole.local",
|
url: "http://hole.local",
|
||||||
secrets: [],
|
secrets: [],
|
||||||
|
appId: null,
|
||||||
});
|
});
|
||||||
await expect(actAsync()).rejects.toThrow("Integration not found");
|
await expect(actAsync()).rejects.toThrow("Integration not found");
|
||||||
});
|
});
|
||||||
@@ -385,6 +520,7 @@ describe("update should update an integration", () => {
|
|||||||
name: "Pi Hole",
|
name: "Pi Hole",
|
||||||
url: "http://hole.local",
|
url: "http://hole.local",
|
||||||
secrets: [],
|
secrets: [],
|
||||||
|
appId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
|||||||
value: "secret",
|
value: "secret",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
externalUrl: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,6 +105,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
|||||||
value: "dbSecret",
|
value: "dbSecret",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
externalUrl: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -153,6 +155,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
|||||||
value: "secret",
|
value: "secret",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
externalUrl: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -206,6 +209,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
|||||||
value: "secret",
|
value: "secret",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
externalUrl: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -263,6 +267,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
|||||||
value: "dbPassword",
|
value: "dbPassword",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
externalUrl: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -336,6 +341,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
|
|||||||
value: "privateKey",
|
value: "privateKey",
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
externalUrl: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `integration` ADD `app_id` varchar(128);--> statement-breakpoint
|
||||||
|
ALTER TABLE `integration` ADD CONSTRAINT `integration_app_id_app_id_fk` FOREIGN KEY (`app_id`) REFERENCES `app`(`id`) ON DELETE set null ON UPDATE no action;
|
||||||
2110
packages/db/migrations/mysql/meta/0036_snapshot.json
Normal file
2110
packages/db/migrations/mysql/meta/0036_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -253,6 +253,13 @@
|
|||||||
"when": 1756701556908,
|
"when": 1756701556908,
|
||||||
"tag": "0035_increase-secret-kind-length",
|
"tag": "0035_increase-secret-kind-length",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 36,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1760968518445,
|
||||||
|
"tag": "0036_add_app_reference_to_integration",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "integration" ADD COLUMN "app_id" varchar(128);--> statement-breakpoint
|
||||||
|
ALTER TABLE "integration" ADD CONSTRAINT "integration_app_id_app_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."app"("id") ON DELETE set null ON UPDATE no action;
|
||||||
2007
packages/db/migrations/postgresql/meta/0002_snapshot.json
Normal file
2007
packages/db/migrations/postgresql/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
|||||||
"when": 1756701573101,
|
"when": 1756701573101,
|
||||||
"tag": "0001_increase-secret-kind-length",
|
"tag": "0001_increase-secret-kind-length",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1760968530084,
|
||||||
|
"tag": "0002_add_app_reference_to_integration",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ const seedDefaultIntegrationsAsync = async (db: Database) => {
|
|||||||
name: `${name} Default`,
|
name: `${name} Default`,
|
||||||
url: defaultUrl,
|
url: defaultUrl,
|
||||||
kind,
|
kind,
|
||||||
|
appId: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `integration` ADD `app_id` text REFERENCES app(id);
|
||||||
2025
packages/db/migrations/sqlite/meta/0034_snapshot.json
Normal file
2025
packages/db/migrations/sqlite/meta/0034_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -239,6 +239,13 @@
|
|||||||
"when": 1750014001941,
|
"when": 1750014001941,
|
||||||
"tag": "0033_add_cron_job_configuration",
|
"tag": "0033_add_cron_job_configuration",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 34,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1760968503571,
|
||||||
|
"tag": "0034_add_app_reference_to_integration",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const getItemsWithIntegrationsAsync = async <TKind extends WidgetKind>(
|
|||||||
with: {
|
with: {
|
||||||
integration: {
|
integration: {
|
||||||
with: {
|
with: {
|
||||||
|
app: true,
|
||||||
secrets: {
|
secrets: {
|
||||||
columns: {
|
columns: {
|
||||||
kind: true,
|
kind: true,
|
||||||
|
|||||||
@@ -199,6 +199,7 @@ export const integrations = mysqlTable(
|
|||||||
name: text().notNull(),
|
name: text().notNull(),
|
||||||
url: text().notNull(),
|
url: text().notNull(),
|
||||||
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
|
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
|
||||||
|
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
|
||||||
},
|
},
|
||||||
(integrations) => ({
|
(integrations) => ({
|
||||||
kindIdx: index("integration__kind_idx").on(integrations.kind),
|
kindIdx: index("integration__kind_idx").on(integrations.kind),
|
||||||
@@ -627,11 +628,15 @@ export const boardGroupPermissionRelations = relations(boardGroupPermissions, ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const integrationRelations = relations(integrations, ({ many }) => ({
|
export const integrationRelations = relations(integrations, ({ one, many }) => ({
|
||||||
secrets: many(integrationSecrets),
|
secrets: many(integrationSecrets),
|
||||||
items: many(integrationItems),
|
items: many(integrationItems),
|
||||||
userPermissions: many(integrationUserPermissions),
|
userPermissions: many(integrationUserPermissions),
|
||||||
groupPermissions: many(integrationGroupPermissions),
|
groupPermissions: many(integrationGroupPermissions),
|
||||||
|
app: one(apps, {
|
||||||
|
fields: [integrations.appId],
|
||||||
|
references: [apps.id],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
|
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ export const integrations = pgTable(
|
|||||||
name: text().notNull(),
|
name: text().notNull(),
|
||||||
url: text().notNull(),
|
url: text().notNull(),
|
||||||
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
|
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
|
||||||
|
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
|
||||||
},
|
},
|
||||||
(integrations) => ({
|
(integrations) => ({
|
||||||
kindIdx: index("integration__kind_idx").on(integrations.kind),
|
kindIdx: index("integration__kind_idx").on(integrations.kind),
|
||||||
@@ -626,11 +627,15 @@ export const boardGroupPermissionRelations = relations(boardGroupPermissions, ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const integrationRelations = relations(integrations, ({ many }) => ({
|
export const integrationRelations = relations(integrations, ({ one, many }) => ({
|
||||||
secrets: many(integrationSecrets),
|
secrets: many(integrationSecrets),
|
||||||
items: many(integrationItems),
|
items: many(integrationItems),
|
||||||
userPermissions: many(integrationUserPermissions),
|
userPermissions: many(integrationUserPermissions),
|
||||||
groupPermissions: many(integrationGroupPermissions),
|
groupPermissions: many(integrationGroupPermissions),
|
||||||
|
app: one(apps, {
|
||||||
|
fields: [integrations.appId],
|
||||||
|
references: [apps.id],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
|
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ export const integrations = sqliteTable(
|
|||||||
name: text().notNull(),
|
name: text().notNull(),
|
||||||
url: text().notNull(),
|
url: text().notNull(),
|
||||||
kind: text().$type<IntegrationKind>().notNull(),
|
kind: text().$type<IntegrationKind>().notNull(),
|
||||||
|
appId: text().references(() => apps.id, { onDelete: "set null" }),
|
||||||
},
|
},
|
||||||
(integrations) => ({
|
(integrations) => ({
|
||||||
kindIdx: index("integration__kind_idx").on(integrations.kind),
|
kindIdx: index("integration__kind_idx").on(integrations.kind),
|
||||||
@@ -612,11 +613,15 @@ export const boardGroupPermissionRelations = relations(boardGroupPermissions, ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const integrationRelations = relations(integrations, ({ many }) => ({
|
export const integrationRelations = relations(integrations, ({ one, many }) => ({
|
||||||
secrets: many(integrationSecrets),
|
secrets: many(integrationSecrets),
|
||||||
items: many(integrationItems),
|
items: many(integrationItems),
|
||||||
userPermissions: many(integrationUserPermissions),
|
userPermissions: many(integrationUserPermissions),
|
||||||
groupPermissions: many(integrationGroupPermissions),
|
groupPermissions: many(integrationGroupPermissions),
|
||||||
|
app: one(apps, {
|
||||||
|
fields: [integrations.appId],
|
||||||
|
references: [apps.id],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
|
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import { decryptSecret } from "@homarr/common/server";
|
import type { IntegrationKind } from "@homarr/definitions";
|
||||||
import type { Modify } from "@homarr/common/types";
|
|
||||||
import type { Integration as DbIntegration } from "@homarr/db/schema";
|
|
||||||
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
|
|
||||||
|
|
||||||
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
|
||||||
import { CodebergIntegration } from "../codeberg/codeberg-integration";
|
import { CodebergIntegration } from "../codeberg/codeberg-integration";
|
||||||
@@ -62,20 +59,6 @@ export const createIntegrationAsync = async <TKind extends keyof typeof integrat
|
|||||||
return new creator(integration) as IntegrationInstanceOfKind<TKind>;
|
return new creator(integration) as IntegrationInstanceOfKind<TKind>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createIntegrationAsyncFromSecrets = <TKind extends keyof typeof integrationCreators>(
|
|
||||||
integration: Modify<DbIntegration, { kind: TKind }> & {
|
|
||||||
secrets: { kind: IntegrationSecretKind; value: `${string}.${string}` }[];
|
|
||||||
},
|
|
||||||
) => {
|
|
||||||
return createIntegrationAsync({
|
|
||||||
...integration,
|
|
||||||
decryptedSecrets: integration.secrets.map((secret) => ({
|
|
||||||
...secret,
|
|
||||||
value: decryptSecret(secret.value),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
type IntegrationInstance = new (integration: IntegrationInput) => Integration;
|
type IntegrationInstance = new (integration: IntegrationInput) => Integration;
|
||||||
|
|
||||||
// factories are an array, to differentiate in js between class constructors and functions
|
// factories are an array, to differentiate in js between class constructors and functions
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface IntegrationInput {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
externalUrl: string | null;
|
||||||
decryptedSecrets: IntegrationSecret[];
|
decryptedSecrets: IntegrationSecret[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,8 +55,12 @@ export abstract class Integration {
|
|||||||
return this.integration.decryptedSecrets.some((secret) => secret.kind === kind);
|
return this.integration.decryptedSecrets.some((secret) => secret.kind === kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected url(path: `/${string}`, queryParams?: Record<string, string | Date | number | boolean>) {
|
private createUrl(
|
||||||
const baseUrl = removeTrailingSlash(this.integration.url);
|
inputUrl: string,
|
||||||
|
path: `/${string}`,
|
||||||
|
queryParams?: Record<string, string | Date | number | boolean>,
|
||||||
|
) {
|
||||||
|
const baseUrl = removeTrailingSlash(inputUrl);
|
||||||
const url = new URL(`${baseUrl}${path}`);
|
const url = new URL(`${baseUrl}${path}`);
|
||||||
|
|
||||||
if (queryParams) {
|
if (queryParams) {
|
||||||
@@ -66,6 +71,13 @@ export abstract class Integration {
|
|||||||
|
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
protected url(path: `/${string}`, queryParams?: Record<string, string | Date | number | boolean>) {
|
||||||
|
return this.createUrl(this.integration.url, path, queryParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected externalUrl(path: `/${string}`, queryParams?: Record<string, string | Date | number | boolean>) {
|
||||||
|
return this.createUrl(this.integration.externalUrl ?? this.integration.url, path, queryParams);
|
||||||
|
}
|
||||||
|
|
||||||
public async testConnectionAsync(): Promise<TestingResult> {
|
public async testConnectionAsync(): Promise<TestingResult> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
|
|||||||
sessionId: `${sessionInfo.Id}`,
|
sessionId: `${sessionInfo.Id}`,
|
||||||
sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`,
|
sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`,
|
||||||
user: {
|
user: {
|
||||||
profilePictureUrl: super.url(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
|
profilePictureUrl: super.externalUrl(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
|
||||||
userId: sessionInfo.UserId ?? "",
|
userId: sessionInfo.UserId ?? "",
|
||||||
username: sessionInfo.UserName ?? "",
|
username: sessionInfo.UserName ?? "",
|
||||||
},
|
},
|
||||||
@@ -169,13 +169,13 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
|
|||||||
description: item.Overview,
|
description: item.Overview,
|
||||||
releaseDate: item.PremiereDate ?? item.DateCreated,
|
releaseDate: item.PremiereDate ?? item.DateCreated,
|
||||||
imageUrls: {
|
imageUrls: {
|
||||||
poster: super.url(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
|
poster: super.externalUrl(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
|
||||||
backdrop: super.url(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
|
backdrop: super.externalUrl(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
|
||||||
},
|
},
|
||||||
producer: item.Studios.at(0)?.Name,
|
producer: item.Studios.at(0)?.Name,
|
||||||
rating: item.CommunityRating?.toFixed(1),
|
rating: item.CommunityRating?.toFixed(1),
|
||||||
tags: item.Genres,
|
tags: item.Genres,
|
||||||
href: super.url(`/web/index.html#!/item?id=${item.Id}&serverId=${item.ServerId}`).toString(),
|
href: super.externalUrl(`/web/index.html#!/item?id=${item.Id}&serverId=${item.ServerId}`).toString(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,4 +54,4 @@ export type { Notification } from "./interfaces/notifications/notification-types
|
|||||||
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
|
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
export { createIntegrationAsync, createIntegrationAsyncFromSecrets } from "./base/creator";
|
export { createIntegrationAsync } from "./base/creator";
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
|||||||
sessionId: `${sessionInfo.Id}`,
|
sessionId: `${sessionInfo.Id}`,
|
||||||
sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`,
|
sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`,
|
||||||
user: {
|
user: {
|
||||||
profilePictureUrl: this.url(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
|
profilePictureUrl: this.externalUrl(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
|
||||||
userId: sessionInfo.UserId ?? "",
|
userId: sessionInfo.UserId ?? "",
|
||||||
username: sessionInfo.UserName ?? "",
|
username: sessionInfo.UserName ?? "",
|
||||||
},
|
},
|
||||||
@@ -130,13 +130,13 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
releaseDate: new Date(item.PremiereDate ?? item.DateCreated!),
|
releaseDate: new Date(item.PremiereDate ?? item.DateCreated!),
|
||||||
imageUrls: {
|
imageUrls: {
|
||||||
poster: super.url(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
|
poster: super.externalUrl(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
|
||||||
backdrop: super.url(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
|
backdrop: super.externalUrl(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
|
||||||
},
|
},
|
||||||
producer: item.Studios?.at(0)?.Name ?? undefined,
|
producer: item.Studios?.at(0)?.Name ?? undefined,
|
||||||
rating: item.CommunityRating?.toFixed(1),
|
rating: item.CommunityRating?.toFixed(1),
|
||||||
tags: item.Genres ?? [],
|
tags: item.Genres ?? [],
|
||||||
href: super.url(`/web/index.html#!/details?id=${item.Id}&serverId=${item.ServerId}`).toString(),
|
href: super.externalUrl(`/web/index.html#!/details?id=${item.Id}&serverId=${item.ServerId}`).toString(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati
|
|||||||
private getLinksForRadarrCalendarEvent = (event: z.infer<typeof radarrCalendarEventSchema>) => {
|
private getLinksForRadarrCalendarEvent = (event: z.infer<typeof radarrCalendarEventSchema>) => {
|
||||||
const links: CalendarLink[] = [
|
const links: CalendarLink[] = [
|
||||||
{
|
{
|
||||||
href: this.url(`/movie/${event.titleSlug}`).toString(),
|
href: this.externalUrl(`/movie/${event.titleSlug}`).toString(),
|
||||||
name: "Radarr",
|
name: "Radarr",
|
||||||
logo: "/images/apps/radarr.svg",
|
logo: "/images/apps/radarr.svg",
|
||||||
color: undefined,
|
color: undefined,
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export class ReadarrIntegration extends Integration implements ICalendarIntegrat
|
|||||||
private getLinksForReadarrCalendarEvent = (event: z.infer<typeof readarrCalendarEventSchema>) => {
|
private getLinksForReadarrCalendarEvent = (event: z.infer<typeof readarrCalendarEventSchema>) => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
href: this.url(`/author/${event.author.foreignAuthorId}`).toString(),
|
href: this.externalUrl(`/author/${event.author.foreignAuthorId}`).toString(),
|
||||||
color: "#f5c518",
|
color: "#f5c518",
|
||||||
isDark: false,
|
isDark: false,
|
||||||
logo: "/images/apps/readarr.svg",
|
logo: "/images/apps/readarr.svg",
|
||||||
@@ -101,7 +101,7 @@ export class ReadarrIntegration extends Integration implements ICalendarIntegrat
|
|||||||
if (!bestImage) {
|
if (!bestImage) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return this.url(bestImage.url as `/${string}`).toString();
|
return this.externalUrl(bestImage.url as `/${string}`).toString();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati
|
|||||||
private getLinksForSonarrCalendarEvent = (event: z.infer<typeof sonarrCalendarEventSchema>) => {
|
private getLinksForSonarrCalendarEvent = (event: z.infer<typeof sonarrCalendarEventSchema>) => {
|
||||||
const links: CalendarLink[] = [
|
const links: CalendarLink[] = [
|
||||||
{
|
{
|
||||||
href: this.url(`/series/${event.series.titleSlug}`).toString(),
|
href: this.externalUrl(`/series/${event.series.titleSlug}`).toString(),
|
||||||
name: "Sonarr",
|
name: "Sonarr",
|
||||||
logo: "/images/apps/sonarr.svg",
|
logo: "/images/apps/sonarr.svg",
|
||||||
color: undefined,
|
color: undefined,
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export class NextcloudIntegration extends Integration implements ICalendarIntegr
|
|||||||
"color" in veventObject && typeof veventObject.color === "string" ? veventObject.color : "#ff8600",
|
"color" in veventObject && typeof veventObject.color === "string" ? veventObject.color : "#ff8600",
|
||||||
links: [
|
links: [
|
||||||
{
|
{
|
||||||
href: this.url(
|
href: this.externalUrl(
|
||||||
`/apps/calendar/timeGridWeek/now/edit/sidebar/${eventSlug}/${dateInMillis / 1000}`,
|
`/apps/calendar/timeGridWeek/now/edit/sidebar/${eventSlug}/${dateInMillis / 1000}`,
|
||||||
).toString(),
|
).toString(),
|
||||||
name: "Nextcloud",
|
name: "Nextcloud",
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export class NTFYIntegration extends Integration implements INotificationsIntegr
|
|||||||
return notifications
|
return notifications
|
||||||
.filter((notification) => notification !== null)
|
.filter((notification) => notification !== null)
|
||||||
.map((notification): Notification => {
|
.map((notification): Notification => {
|
||||||
const topicURL = this.url(`/${notification.topic}`);
|
const topicURL = this.externalUrl(`/${notification.topic}`);
|
||||||
return {
|
return {
|
||||||
id: notification.id,
|
id: notification.id,
|
||||||
time: new Date(notification.time * 1000),
|
time: new Date(notification.time * 1000),
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export class OverseerrIntegration
|
|||||||
return schemaData.results.map((result) => ({
|
return schemaData.results.map((result) => ({
|
||||||
id: result.id,
|
id: result.id,
|
||||||
name: "name" in result ? result.name : result.title,
|
name: "name" in result ? result.name : result.title,
|
||||||
link: this.url(`/${result.mediaType}/${result.id}`).toString(),
|
link: this.externalUrl(`/${result.mediaType}/${result.id}`).toString(),
|
||||||
image: constructSearchResultImage(result),
|
image: constructSearchResultImage(result),
|
||||||
text: "overview" in result ? result.overview : undefined,
|
text: "overview" in result ? result.overview : undefined,
|
||||||
type: result.mediaType,
|
type: result.mediaType,
|
||||||
@@ -144,7 +144,7 @@ export class OverseerrIntegration
|
|||||||
availability: request.media.status,
|
availability: request.media.status,
|
||||||
backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`,
|
backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`,
|
||||||
posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`,
|
posterImagePath: `https://image.tmdb.org/t/p/w600_and_h900_bestv2/${information.posterPath}`,
|
||||||
href: this.url(`/${request.type}/${request.media.tmdbId}`).toString(),
|
href: this.externalUrl(`/${request.type}/${request.media.tmdbId}`).toString(),
|
||||||
type: request.type,
|
type: request.type,
|
||||||
createdAt: request.createdAt,
|
createdAt: request.createdAt,
|
||||||
airDate: new Date(information.airDate),
|
airDate: new Date(information.airDate),
|
||||||
@@ -152,7 +152,7 @@ export class OverseerrIntegration
|
|||||||
? ({
|
? ({
|
||||||
...request.requestedBy,
|
...request.requestedBy,
|
||||||
displayName: request.requestedBy.displayName,
|
displayName: request.requestedBy.displayName,
|
||||||
link: this.url(`/users/${request.requestedBy.id}`).toString(),
|
link: this.externalUrl(`/users/${request.requestedBy.id}`).toString(),
|
||||||
avatar: this.constructAvatarUrl(request.requestedBy.avatar).toString(),
|
avatar: this.constructAvatarUrl(request.requestedBy.avatar).toString(),
|
||||||
} satisfies Omit<RequestUser, "requestCount">)
|
} satisfies Omit<RequestUser, "requestCount">)
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -180,7 +180,7 @@ export class OverseerrIntegration
|
|||||||
return users.map((user): RequestUser => {
|
return users.map((user): RequestUser => {
|
||||||
return {
|
return {
|
||||||
...user,
|
...user,
|
||||||
link: this.url(`/users/${user.id}`).toString(),
|
link: this.externalUrl(`/users/${user.id}`).toString(),
|
||||||
avatar: this.constructAvatarUrl(user.avatar).toString(),
|
avatar: this.constructAvatarUrl(user.avatar).toString(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -255,7 +255,7 @@ export class OverseerrIntegration
|
|||||||
return avatar;
|
return avatar;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.url(`/${avatar}`);
|
return this.externalUrl(`/${avatar}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ const createAria2Intergration = (container: StartedTestContainer, apikey: string
|
|||||||
],
|
],
|
||||||
name: "Aria2",
|
name: "Aria2",
|
||||||
url: `http://${container.getHost()}:${container.getMappedPort(8080)}`,
|
url: `http://${container.getHost()}:${container.getMappedPort(8080)}`,
|
||||||
|
externalUrl: null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class FakeIntegration extends Integration {
|
|||||||
name: "Test",
|
name: "Test",
|
||||||
url: "https://example.com",
|
url: "https://example.com",
|
||||||
decryptedSecrets: [],
|
decryptedSecrets: [],
|
||||||
|
externalUrl: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,5 +97,6 @@ const createHomeAssistantIntegration = (container: StartedTestContainer, apiKeyO
|
|||||||
],
|
],
|
||||||
name: "Home assistant",
|
name: "Home assistant",
|
||||||
url: `http://${container.getHost()}:${container.getMappedPort(8123)}`,
|
url: `http://${container.getHost()}:${container.getMappedPort(8123)}`,
|
||||||
|
externalUrl: null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ const createNzbGetIntegration = (container: StartedTestContainer, username: stri
|
|||||||
],
|
],
|
||||||
name: "NzbGet",
|
name: "NzbGet",
|
||||||
url: `http://${container.getHost()}:${container.getMappedPort(6789)}`,
|
url: `http://${container.getHost()}:${container.getMappedPort(6789)}`,
|
||||||
|
externalUrl: null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ const createPiHoleIntegrationV5 = (container: StartedTestContainer, apiKey: stri
|
|||||||
],
|
],
|
||||||
name: "Pi hole",
|
name: "Pi hole",
|
||||||
url: `http://${container.getHost()}:${container.getMappedPort(80)}`,
|
url: `http://${container.getHost()}:${container.getMappedPort(80)}`,
|
||||||
|
externalUrl: null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -233,5 +234,6 @@ const createPiHoleIntegrationV6 = (container: StartedTestContainer, apiKey: stri
|
|||||||
],
|
],
|
||||||
name: "Pi hole",
|
name: "Pi hole",
|
||||||
url: `http://${container.getHost()}:${container.getMappedPort(80)}`,
|
url: `http://${container.getHost()}:${container.getMappedPort(80)}`,
|
||||||
|
externalUrl: null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ const createSabnzbdIntegration = (container: StartedTestContainer, apiKey: strin
|
|||||||
],
|
],
|
||||||
name: "Sabnzbd",
|
name: "Sabnzbd",
|
||||||
url: `http://${container.getHost()}:${container.getMappedPort(1212)}`,
|
url: `http://${container.getHost()}:${container.getMappedPort(1212)}`,
|
||||||
|
externalUrl: null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
|
|||||||
import { Button, Card, Center, Grid, Input, Stack, Text } from "@mantine/core";
|
import { Button, Card, Center, Grid, Input, Stack, Text } from "@mantine/core";
|
||||||
import { IconPlus, IconSearch } from "@tabler/icons-react";
|
import { IconPlus, IconSearch } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { createModal, useModalAction } from "@homarr/modals";
|
import { createModal, useModalAction } from "@homarr/modals";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
@@ -9,7 +10,8 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
import { QuickAddAppModal } from "./quick-add-app/quick-add-app-modal";
|
||||||
|
|
||||||
interface AppSelectModalProps {
|
interface AppSelectModalProps {
|
||||||
onSelect?: (appId: string) => void;
|
onSelect?: (app: RouterOutputs["app"]["selectable"][number]) => void;
|
||||||
|
withCreate: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, innerProps }) => {
|
export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, innerProps }) => {
|
||||||
@@ -26,18 +28,18 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
|||||||
[apps, search],
|
[apps, search],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelect = (appId: string) => {
|
const handleSelect = (app: RouterOutputs["app"]["selectable"][number]) => {
|
||||||
if (innerProps.onSelect) {
|
if (innerProps.onSelect) {
|
||||||
innerProps.onSelect(appId);
|
innerProps.onSelect(app);
|
||||||
}
|
}
|
||||||
actions.closeModal();
|
actions.closeModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddNewApp = () => {
|
const handleAddNewApp = () => {
|
||||||
openQuickAddAppModal({
|
openQuickAddAppModal({
|
||||||
onClose(createdAppId) {
|
onClose(app) {
|
||||||
if (innerProps.onSelect) {
|
if (innerProps.onSelect) {
|
||||||
innerProps.onSelect(createdAppId);
|
innerProps.onSelect(app);
|
||||||
}
|
}
|
||||||
actions.closeModal();
|
actions.closeModal();
|
||||||
},
|
},
|
||||||
@@ -54,32 +56,34 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
|||||||
data-autofocus
|
data-autofocus
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
|
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
|
||||||
handleSelect(filteredApps[0].id);
|
handleSelect(filteredApps[0]);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Grid>
|
<Grid>
|
||||||
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
|
{innerProps.withCreate && (
|
||||||
<Card h="100%">
|
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
|
||||||
<Stack justify="space-between" h="100%">
|
<Card h="100%">
|
||||||
<Stack gap="xs">
|
<Stack justify="space-between" h="100%">
|
||||||
<Center>
|
<Stack gap="xs">
|
||||||
<IconPlus size={24} />
|
<Center>
|
||||||
</Center>
|
<IconPlus size={24} />
|
||||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
|
</Center>
|
||||||
{t("app.action.create.title")}
|
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
|
||||||
</Text>
|
{t("app.action.create.title")}
|
||||||
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
|
</Text>
|
||||||
{t("app.action.create.description")}
|
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
|
||||||
</Text>
|
{t("app.action.create.description")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Button onClick={handleAddNewApp} variant="light" size="xs" mt="auto" radius="md" fullWidth>
|
||||||
|
{t("app.action.create.action")}
|
||||||
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Button onClick={handleAddNewApp} variant="light" size="xs" mt="auto" radius="md" fullWidth>
|
</Card>
|
||||||
{t("app.action.create.action")}
|
</Grid.Col>
|
||||||
</Button>
|
)}
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
</Grid.Col>
|
|
||||||
|
|
||||||
{filteredApps.map((app) => (
|
{filteredApps.map((app) => (
|
||||||
<Grid.Col key={app.id} span={{ xs: 12, sm: 4, md: 3 }}>
|
<Grid.Col key={app.id} span={{ xs: 12, sm: 4, md: 3 }}>
|
||||||
@@ -96,7 +100,7 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
|
|||||||
{app.description ?? ""}
|
{app.description ?? ""}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Button onClick={() => handleSelect(app.id)} variant="light" size="xs" mt="auto" radius="md" fullWidth>
|
<Button onClick={() => handleSelect(app)} variant="light" size="xs" mt="auto" radius="md" fullWidth>
|
||||||
{t("app.action.select.action", { app: app.name })}
|
{t("app.action.select.action", { app: app.name })}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { z } from "zod/v4";
|
import type { z } from "zod/v4";
|
||||||
|
|
||||||
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import type { MaybePromise } from "@homarr/common/types";
|
import type { MaybePromise } from "@homarr/common/types";
|
||||||
import { AppForm } from "@homarr/forms-collection";
|
import { AppForm } from "@homarr/forms-collection";
|
||||||
@@ -9,7 +10,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
|||||||
import type { appManageSchema } from "@homarr/validation/app";
|
import type { appManageSchema } from "@homarr/validation/app";
|
||||||
|
|
||||||
interface QuickAddAppModalProps {
|
interface QuickAddAppModalProps {
|
||||||
onClose: (createdAppId: string) => MaybePromise<void>;
|
onClose: (createdApp: Omit<RouterOutputs["app"]["create"], "appId">) => MaybePromise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QuickAddAppModal = createModal<QuickAddAppModalProps>(({ actions, innerProps }) => {
|
export const QuickAddAppModal = createModal<QuickAddAppModalProps>(({ actions, innerProps }) => {
|
||||||
@@ -27,13 +28,13 @@ export const QuickAddAppModal = createModal<QuickAddAppModalProps>(({ actions, i
|
|||||||
|
|
||||||
const handleSubmit = (values: z.infer<typeof appManageSchema>) => {
|
const handleSubmit = (values: z.infer<typeof appManageSchema>) => {
|
||||||
mutate(values, {
|
mutate(values, {
|
||||||
async onSuccess({ appId }) {
|
async onSuccess(app) {
|
||||||
showSuccessNotification({
|
showSuccessNotification({
|
||||||
title: tScoped("success.title"),
|
title: tScoped("success.title"),
|
||||||
message: tScoped("success.message"),
|
message: tScoped("success.message"),
|
||||||
});
|
});
|
||||||
|
|
||||||
await innerProps.onClose(appId);
|
await innerProps.onClose(app);
|
||||||
actions.closeModal();
|
actions.closeModal();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { createCachedRequestHandler } from "./cached-request-handler";
|
|||||||
type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"> & {
|
type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"> & {
|
||||||
kind: TKind;
|
kind: TKind;
|
||||||
decryptedSecrets: Modify<Pick<IntegrationSecret, "kind" | "value">, { value: string }>[];
|
decryptedSecrets: Modify<Pick<IntegrationSecret, "kind" | "value">, { value: string }>[];
|
||||||
|
externalUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
|
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export const createRequestIntegrationJobHandler = <
|
|||||||
...integration,
|
...integration,
|
||||||
kind: integration.kind as TIntegrationKind,
|
kind: integration.kind as TIntegrationKind,
|
||||||
decryptedSecrets,
|
decryptedSecrets,
|
||||||
|
externalUrl: integration.app?.href ?? null,
|
||||||
},
|
},
|
||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -645,6 +645,21 @@
|
|||||||
"title": "Creation failed",
|
"title": "Creation failed",
|
||||||
"message": "The integration could not be created"
|
"message": "The integration could not be created"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"option": {
|
||||||
|
"existing": {
|
||||||
|
"title": "Existing",
|
||||||
|
"label": "Select existing app"
|
||||||
|
},
|
||||||
|
"new": {
|
||||||
|
"title": "New",
|
||||||
|
"url": {
|
||||||
|
"label": "App url",
|
||||||
|
"description": "The url the app will open when accessed from the dashboard"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
@@ -658,6 +673,13 @@
|
|||||||
"title": "Unable to apply changes",
|
"title": "Unable to apply changes",
|
||||||
"message": "The integration could not be saved"
|
"message": "The integration could not be saved"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"action": {
|
||||||
|
"add": "Link an app",
|
||||||
|
"remove": "Unlink",
|
||||||
|
"select": "Select an app to link"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"delete": {
|
"delete": {
|
||||||
@@ -686,6 +708,9 @@
|
|||||||
"label": "Create Search Engine",
|
"label": "Create Search Engine",
|
||||||
"description": "Integration \"{kind}\" can be used with the search engines. Check this to automatically configure the search engine."
|
"description": "Integration \"{kind}\" can be used with the search engines. Check this to automatically configure the search engine."
|
||||||
},
|
},
|
||||||
|
"app": {
|
||||||
|
"sectionTitle": "Linked App"
|
||||||
|
},
|
||||||
"createApp": {
|
"createApp": {
|
||||||
"label": "Create app",
|
"label": "Create app",
|
||||||
"description": "Create an app with the same name and icon as the integration. Leave the input field below empty to create the app with the integration URL."
|
"description": "Create an app with the same name and icon as the integration. Leave the input field below empty to create the app with the integration URL."
|
||||||
@@ -1026,6 +1051,7 @@
|
|||||||
"add": "Add",
|
"add": "Add",
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
"backToOverview": "Back to overview",
|
"backToOverview": "Back to overview",
|
||||||
|
"change": "Change",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"createAnother": "Create and start over",
|
"createAnother": "Create and start over",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { z } from "zod/v4";
|
|||||||
|
|
||||||
import { integrationKinds, integrationPermissions, integrationSecretKinds } from "@homarr/definitions";
|
import { integrationKinds, integrationPermissions, integrationSecretKinds } from "@homarr/definitions";
|
||||||
|
|
||||||
|
import { appManageSchema } from "./app";
|
||||||
import { zodEnumFromArray } from "./enums";
|
import { zodEnumFromArray } from "./enums";
|
||||||
import { createSavePermissionsSchema } from "./permissions";
|
import { createSavePermissionsSchema } from "./permissions";
|
||||||
|
|
||||||
@@ -19,6 +20,12 @@ export const integrationCreateSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
attemptSearchEngineCreation: z.boolean(),
|
attemptSearchEngineCreation: z.boolean(),
|
||||||
|
app: z
|
||||||
|
.object({
|
||||||
|
id: z.string(),
|
||||||
|
})
|
||||||
|
.or(appManageSchema)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const integrationUpdateSchema = z.object({
|
export const integrationUpdateSchema = z.object({
|
||||||
@@ -31,6 +38,7 @@ export const integrationUpdateSchema = z.object({
|
|||||||
value: z.string().nullable(),
|
value: z.string().nullable(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
appId: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const integrationSavePermissionsSchema = createSavePermissionsSchema(zodEnumFromArray(integrationPermissions));
|
export const integrationSavePermissionsSchema = createSavePermissionsSchema(zodEnumFromArray(integrationPermissions));
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { IconCheck, IconRocket } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import type { RouterOutputs } from "@homarr/api";
|
import type { RouterOutputs } from "@homarr/api";
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
|
import { useSession } from "@homarr/auth/client";
|
||||||
import { useModalAction } from "@homarr/modals";
|
import { useModalAction } from "@homarr/modals";
|
||||||
import { QuickAddAppModal } from "@homarr/modals-collection";
|
import { QuickAddAppModal } from "@homarr/modals-collection";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
@@ -21,6 +22,8 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
|
|||||||
const tInput = useWidgetInputTranslation(kind, property);
|
const tInput = useWidgetInputTranslation(kind, property);
|
||||||
const form = useFormContext();
|
const form = useFormContext();
|
||||||
const { data: apps, isPending, refetch } = clientApi.app.selectable.useQuery();
|
const { data: apps, isPending, refetch } = clientApi.app.selectable.useQuery();
|
||||||
|
const { data: session } = useSession();
|
||||||
|
const canCreateApps = session?.user.permissions.includes("app-create") ?? false;
|
||||||
|
|
||||||
const { openModal } = useModalAction(QuickAddAppModal);
|
const { openModal } = useModalAction(QuickAddAppModal);
|
||||||
|
|
||||||
@@ -30,7 +33,7 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SimpleGrid cols={{ base: 1, md: 2 }} spacing={{ base: "md" }} style={{ alignItems: "center" }}>
|
<SimpleGrid cols={{ base: 1, md: canCreateApps ? 2 : 1 }} spacing={{ base: "md" }} style={{ alignItems: "center" }}>
|
||||||
<Select
|
<Select
|
||||||
label={tInput("label")}
|
label={tInput("label")}
|
||||||
searchable
|
searchable
|
||||||
@@ -59,22 +62,24 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
|
|||||||
styles={{ root: { flex: "1" } }}
|
styles={{ root: { flex: "1" } }}
|
||||||
{...form.getInputProps(`options.${property}`)}
|
{...form.getInputProps(`options.${property}`)}
|
||||||
/>
|
/>
|
||||||
<Button
|
{canCreateApps && (
|
||||||
mt={3}
|
<Button
|
||||||
rightSection={<IconRocket size="1.5rem" />}
|
mt={3}
|
||||||
variant="default"
|
rightSection={<IconRocket size="1.5rem" />}
|
||||||
onClick={() =>
|
variant="default"
|
||||||
openModal({
|
onClick={() =>
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
openModal({
|
||||||
async onClose(createdAppId) {
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
await refetch();
|
async onClose(createdAppId) {
|
||||||
form.setFieldValue(`options.${property}`, createdAppId);
|
await refetch();
|
||||||
},
|
form.setFieldValue(`options.${property}`, createdAppId);
|
||||||
})
|
},
|
||||||
}
|
})
|
||||||
>
|
}
|
||||||
{t("widget.common.app.quickCreate")}
|
>
|
||||||
</Button>
|
{t("widget.common.app.quickCreate")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user