chore(release): automatic release v1.43.0

This commit is contained in:
homarr-releases[bot]
2025-10-24 19:14:56 +00:00
committed by GitHub
96 changed files with 7643 additions and 997 deletions

View File

@@ -33,6 +33,7 @@ body:
options: options:
# The below comment is used to insert a new version with on-release.yml # The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION# #NEXT_VERSION#
- 1.42.1
- 1.42.0 - 1.42.0
- 1.41.0 - 1.41.0
- 1.40.0 - 1.40.0

View File

@@ -3,12 +3,12 @@
extends: ["config:recommended"], extends: ["config:recommended"],
packageRules: [ packageRules: [
{ {
matchPackagePatterns: ["^@homarr/"], matchPackageNames: ["^@homarr/"],
enabled: false, enabled: false,
}, },
// Reenable once https://github.com/privatenumber/tsx/issues/737 is fixed // Reenable once https://github.com/privatenumber/tsx/issues/737 is fixed
{ {
matchPackagePatterns: ["tsx"], matchPackageNames: ["tsx"],
enabled: false, enabled: false,
}, },
{ {

View File

@@ -0,0 +1,18 @@
name: "[Renovate] Validate configuration"
permissions:
contents: read
on:
pull_request:
branches: ["*"]
paths: [".github/renovate.json5"]
jobs:
renovate-validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: |
npx --yes --package renovate -- \
renovate-config-validator --strict .github/renovate.json5

View File

@@ -50,15 +50,15 @@
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0", "@homarr/widgets": "workspace:^0.1.0",
"@mantine/colors-generator": "^8.3.4", "@mantine/colors-generator": "^8.3.5",
"@mantine/core": "^8.3.4", "@mantine/core": "^8.3.5",
"@mantine/dropzone": "^8.3.4", "@mantine/dropzone": "^8.3.5",
"@mantine/hooks": "^8.3.4", "@mantine/hooks": "^8.3.5",
"@mantine/modals": "^8.3.4", "@mantine/modals": "^8.3.5",
"@mantine/tiptap": "^8.3.4", "@mantine/tiptap": "^8.3.5",
"@million/lint": "1.0.14", "@million/lint": "1.0.14",
"@tabler/icons-react": "^3.35.0", "@tabler/icons-react": "^3.35.0",
"@tanstack/react-query": "^5.90.2", "@tanstack/react-query": "^5.90.5",
"@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-query-next-experimental": "^5.90.2", "@tanstack/react-query-next-experimental": "^5.90.2",
"@trpc/client": "^11.6.0", "@trpc/client": "^11.6.0",
@@ -76,7 +76,7 @@
"glob": "^11.0.3", "glob": "^11.0.3",
"jotai": "^2.15.0", "jotai": "^2.15.0",
"mantine-react-table": "2.0.0-beta.9", "mantine-react-table": "2.0.0-beta.9",
"next": "15.5.5", "next": "15.5.6",
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.18.0",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"react": "19.2.0", "react": "19.2.0",
@@ -85,7 +85,7 @@
"react-simple-code-editor": "^0.14.1", "react-simple-code-editor": "^0.14.1",
"sass": "^1.93.2", "sass": "^1.93.2",
"superjson": "2.2.2", "superjson": "2.2.2",
"swagger-ui-react": "^5.29.4", "swagger-ui-react": "^5.29.5",
"use-deep-compare-effect": "^1.8.1", "use-deep-compare-effect": "^1.8.1",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
@@ -94,13 +94,13 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/chroma-js": "3.1.1", "@types/chroma-js": "3.1.1",
"@types/node": "^22.18.10", "@types/node": "^22.18.11",
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.5",
"@types/react": "19.2.2", "@types/react": "19.2.2",
"@types/react-dom": "19.2.2", "@types/react-dom": "19.2.2",
"@types/swagger-ui-react": "^5.18.0", "@types/swagger-ui-react": "^5.18.0",
"concurrently": "^9.2.1", "concurrently": "^9.2.1",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"node-loader": "^2.1.0", "node-loader": "^2.1.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"typescript": "^5.9.3" "typescript": "^5.9.3"

View File

@@ -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]);

View File

@@ -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>
);
};

View File

@@ -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}
/>
);
};

View File

@@ -47,10 +47,10 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "^22.18.10", "@types/node": "^22.18.11",
"dotenv-cli": "^10.0.0", "dotenv-cli": "^10.0.0",
"esbuild": "^0.25.10", "esbuild": "^0.25.11",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"tsx": "4.20.4", "tsx": "4.20.4",
"typescript": "^5.9.3" "typescript": "^5.9.3"

View File

@@ -34,8 +34,8 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"esbuild": "^0.25.10", "esbuild": "^0.25.11",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }

View File

@@ -49,7 +49,7 @@
"@vitest/ui": "^3.2.4", "@vitest/ui": "^3.2.4",
"conventional-changelog-conventionalcommits": "^9.1.0", "conventional-changelog-conventionalcommits": "^9.1.0",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"jsdom": "^27.0.0", "jsdom": "^27.0.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"semantic-release": "^24.2.9", "semantic-release": "^24.2.9",
"testcontainers": "^11.7.1", "testcontainers": "^11.7.1",
@@ -58,7 +58,7 @@
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4" "vitest": "^3.2.4"
}, },
"packageManager": "pnpm@10.18.2", "packageManager": "pnpm@10.18.3",
"engines": { "engines": {
"node": ">=22.20.0" "node": ">=22.20.0"
}, },
@@ -80,20 +80,20 @@
"axios@>=1.0.0 <1.8.2": ">=1.12.2", "axios@>=1.0.0 <1.8.2": ">=1.12.2",
"brace-expansion@>=2.0.0 <=2.0.1": ">=4.0.1", "brace-expansion@>=2.0.0 <=2.0.1": ">=4.0.1",
"brace-expansion@>=1.0.0 <=1.1.11": ">=4.0.1", "brace-expansion@>=1.0.0 <=1.1.11": ">=4.0.1",
"esbuild@<=0.24.2": ">=0.25.10", "esbuild@<=0.24.2": ">=0.25.11",
"form-data@>=4.0.0 <4.0.4": ">=4.0.4", "form-data@>=4.0.0 <4.0.4": ">=4.0.4",
"hono@<4.6.5": ">=4.9.12", "hono@<4.6.5": ">=4.10.2",
"linkifyjs@<4.3.2": ">=4.3.2", "linkifyjs@<4.3.2": ">=4.3.2",
"nanoid@>=4.0.0 <5.0.9": ">=5.1.6", "nanoid@>=4.0.0 <5.0.9": ">=5.1.6",
"prismjs@<1.30.0": ">=1.30.0", "prismjs@<1.30.0": ">=1.30.0",
"proxmox-api>undici": "7.16.0", "proxmox-api>undici": "7.16.0",
"react-is": "^19.2.0", "react-is": "^19.2.0",
"rollup@>=4.0.0 <4.22.4": ">=4.52.4", "rollup@>=4.0.0 <4.22.4": ">=4.52.5",
"sha.js@<=2.4.11": ">=2.4.12", "sha.js@<=2.4.11": ">=2.4.12",
"tar-fs@>=3.0.0 <3.0.9": ">=3.1.1", "tar-fs@>=3.0.0 <3.0.9": ">=3.1.1",
"tar-fs@>=2.0.0 <2.1.3": ">=3.1.1", "tar-fs@>=2.0.0 <2.1.3": ">=3.1.1",
"tmp@<=0.2.3": ">=0.2.5", "tmp@<=0.2.3": ">=0.2.5",
"vite@>=5.0.0 <=5.4.18": ">=7.1.9" "vite@>=5.0.0 <=5.4.18": ">=7.1.11"
}, },
"patchedDependencies": { "patchedDependencies": {
"@types/node-unifi": "patches/@types__node-unifi.patch", "@types/node-unifi": "patches/@types__node-unifi.patch",

View File

@@ -32,7 +32,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -43,13 +43,13 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@kubernetes/client-node": "^1.4.0", "@kubernetes/client-node": "^1.4.0",
"@tanstack/react-query": "^5.90.2", "@tanstack/react-query": "^5.90.5",
"@trpc/client": "^11.6.0", "@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0", "@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0", "@trpc/server": "^11.6.0",
"@trpc/tanstack-react-query": "^11.6.0", "@trpc/tanstack-react-query": "^11.6.0",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
"next": "15.5.5", "next": "15.5.6",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"superjson": "2.2.2", "superjson": "2.2.2",
@@ -60,7 +60,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }

View File

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

View File

@@ -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")

View File

@@ -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;
};

View File

@@ -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();

View File

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

View File

@@ -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,
}); });
}); });
}); });

View File

@@ -35,7 +35,7 @@
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"cookies": "^0.9.1", "cookies": "^0.9.1",
"ldapts": "8.0.9", "ldapts": "8.0.9",
"next": "15.5.5", "next": "15.5.6",
"next-auth": "5.0.0-beta.29", "next-auth": "5.0.0-beta.29",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
@@ -47,7 +47,7 @@
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "6.0.0", "@types/bcrypt": "6.0.0",
"@types/cookies": "0.9.1", "@types/cookies": "0.9.1",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }

View File

@@ -32,7 +32,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -30,7 +30,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -34,8 +34,8 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"esbuild": "^0.25.10", "esbuild": "^0.25.11",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -32,8 +32,8 @@
"@paralleldrive/cuid2": "^2.2.2", "@paralleldrive/cuid2": "^2.2.2",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"dns-caching": "^0.2.7", "dns-caching": "^0.2.7",
"next": "15.5.5", "next": "15.5.6",
"octokit": "^5.0.3", "octokit": "^5.0.4",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"undici": "7.16.0", "undici": "7.16.0",
@@ -44,7 +44,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -32,7 +32,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -29,7 +29,7 @@
"@homarr/core": "workspace:^0.1.0", "@homarr/core": "workspace:^0.1.0",
"@homarr/cron-jobs": "workspace:^0.1.0", "@homarr/cron-jobs": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@tanstack/react-query": "^5.90.2", "@tanstack/react-query": "^5.90.5",
"@trpc/client": "^11.6.0", "@trpc/client": "^11.6.0",
"@trpc/server": "^11.6.0", "@trpc/server": "^11.6.0",
"@trpc/tanstack-react-query": "^11.6.0", "@trpc/tanstack-react-query": "^11.6.0",
@@ -43,7 +43,7 @@
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"@types/react": "19.2.2", "@types/react": "19.2.2",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -33,7 +33,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-cron": "^3.0.11", "@types/node-cron": "^3.0.11",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -44,7 +44,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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
} }
] ]
} }

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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
} }
] ]
} }

View File

@@ -150,6 +150,7 @@ const seedDefaultIntegrationsAsync = async (db: Database) => {
name: `${name} Default`, name: `${name} Default`,
url: defaultUrl, url: defaultUrl,
kind, kind,
appId: null,
}); });
} }

View File

@@ -0,0 +1 @@
ALTER TABLE `integration` ADD `app_id` text REFERENCES app(id);

File diff suppressed because it is too large Load Diff

View File

@@ -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
} }
] ]
} }

View File

@@ -49,7 +49,7 @@
"@homarr/definitions": "workspace:^0.1.0", "@homarr/definitions": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@mantine/core": "^8.3.4", "@mantine/core": "^8.3.5",
"@paralleldrive/cuid2": "^2.2.2", "@paralleldrive/cuid2": "^2.2.2",
"@testcontainers/mysql": "^11.7.1", "@testcontainers/mysql": "^11.7.1",
"@testcontainers/postgresql": "^11.7.1", "@testcontainers/postgresql": "^11.7.1",
@@ -69,8 +69,8 @@
"@types/better-sqlite3": "7.6.13", "@types/better-sqlite3": "7.6.13",
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",
"dotenv-cli": "^10.0.0", "dotenv-cli": "^10.0.0",
"esbuild": "^0.25.10", "esbuild": "^0.25.11",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"tsx": "4.20.4", "tsx": "4.20.4",
"typescript": "^5.9.3" "typescript": "^5.9.3"

View File

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

View File

@@ -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 }) => ({

View File

@@ -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 }) => ({

View File

@@ -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 }) => ({

View File

@@ -31,7 +31,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"tsx": "4.20.4", "tsx": "4.20.4",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }

View File

@@ -33,7 +33,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "^3.3.44", "@types/dockerode": "^3.3.44",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -26,7 +26,7 @@
"@homarr/common": "workspace:^0.1.0", "@homarr/common": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "^8.3.4", "@mantine/form": "^8.3.5",
"mantine-form-zod-resolver": "^1.3.0", "mantine-form-zod-resolver": "^1.3.0",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
@@ -34,7 +34,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -29,7 +29,7 @@
"@homarr/notifications": "workspace:^0.1.0", "@homarr/notifications": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.3.4", "@mantine/core": "^8.3.5",
"react": "19.2.0", "react": "19.2.0",
"zod": "^4.1.12" "zod": "^4.1.12"
}, },
@@ -37,7 +37,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -31,7 +31,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -33,7 +33,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "6.0.0", "@types/bcrypt": "6.0.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -43,8 +43,8 @@
"@octokit/auth-app": "^8.1.1", "@octokit/auth-app": "^8.1.1",
"ical.js": "^2.2.1", "ical.js": "^2.2.1",
"maria2": "^0.4.1", "maria2": "^0.4.1",
"node-ical": "^0.21.0", "node-ical": "^0.22.0",
"octokit": "^5.0.3", "octokit": "^5.0.4",
"proxmox-api": "1.1.1", "proxmox-api": "1.1.1",
"tsdav": "^2.1.5", "tsdav": "^2.1.5",
"undici": "7.16.0", "undici": "7.16.0",
@@ -57,7 +57,7 @@
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/node-unifi": "^2.5.1", "@types/node-unifi": "^2.5.1",
"@types/xml2js": "^0.4.14", "@types/xml2js": "^0.4.14",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

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

View File

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

View File

@@ -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(),
})); }));
} }

View File

@@ -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";

View File

@@ -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(),
})); }));
} }

View File

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

View File

@@ -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();
}; };
} }

View File

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

View File

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

View File

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

View File

@@ -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}`);
} }
} }

View File

@@ -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,
}); });
}; };

View File

@@ -43,6 +43,7 @@ class FakeIntegration extends Integration {
name: "Test", name: "Test",
url: "https://example.com", url: "https://example.com",
decryptedSecrets: [], decryptedSecrets: [],
externalUrl: null,
}); });
} }

View File

@@ -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,
}); });
}; };

View File

@@ -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,
}); });
}; };

View File

@@ -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,
}); });
}; };

View File

@@ -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,
}); });
}; };

View File

@@ -33,7 +33,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -33,10 +33,10 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.3.4", "@mantine/core": "^8.3.5",
"@tabler/icons-react": "^3.35.0", "@tabler/icons-react": "^3.35.0",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"next": "15.5.5", "next": "15.5.6",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"zod": "^4.1.12" "zod": "^4.1.12"
@@ -45,7 +45,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

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

View File

@@ -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();
}, },
}); });

View File

@@ -24,15 +24,15 @@
"dependencies": { "dependencies": {
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^8.3.4", "@mantine/core": "^8.3.5",
"@mantine/hooks": "^8.3.4", "@mantine/hooks": "^8.3.5",
"react": "19.2.0" "react": "19.2.0"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -24,14 +24,14 @@
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/notifications": "^8.3.4", "@mantine/notifications": "^8.3.5",
"@tabler/icons-react": "^3.35.0" "@tabler/icons-react": "^3.35.0"
}, },
"devDependencies": { "devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -37,10 +37,10 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.3.4", "@mantine/core": "^8.3.5",
"@mantine/hooks": "^8.3.4", "@mantine/hooks": "^8.3.5",
"adm-zip": "0.5.16", "adm-zip": "0.5.16",
"next": "15.5.5", "next": "15.5.6",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"superjson": "2.2.2", "superjson": "2.2.2",
@@ -52,7 +52,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/adm-zip": "0.5.7", "@types/adm-zip": "0.5.7",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -30,7 +30,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -34,7 +34,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -30,7 +30,7 @@
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0", "@homarr/redis": "workspace:^0.1.0",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"octokit": "^5.0.3", "octokit": "^5.0.4",
"superjson": "2.2.2", "superjson": "2.2.2",
"undici": "7.16.0", "undici": "7.16.0",
"zod": "^4.1.12" "zod": "^4.1.12"
@@ -39,7 +39,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

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

View File

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

View File

@@ -29,7 +29,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -26,8 +26,8 @@
"@homarr/api": "workspace:^0.1.0", "@homarr/api": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0", "@homarr/db": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0", "@homarr/server-settings": "workspace:^0.1.0",
"@mantine/dates": "^8.3.4", "@mantine/dates": "^8.3.5",
"next": "15.5.5", "next": "15.5.6",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0" "react-dom": "19.2.0"
}, },
@@ -35,7 +35,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -33,12 +33,12 @@
"@homarr/settings": "workspace:^0.1.0", "@homarr/settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "^8.3.4", "@mantine/core": "^8.3.5",
"@mantine/hooks": "^8.3.4", "@mantine/hooks": "^8.3.5",
"@mantine/spotlight": "^8.3.4", "@mantine/spotlight": "^8.3.5",
"@tabler/icons-react": "^3.35.0", "@tabler/icons-react": "^3.35.0",
"jotai": "^2.15.0", "jotai": "^2.15.0",
"next": "15.5.5", "next": "15.5.6",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"use-deep-compare-effect": "^1.8.1" "use-deep-compare-effect": "^1.8.1"
@@ -47,7 +47,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -32,7 +32,7 @@
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"deepmerge": "4.3.1", "deepmerge": "4.3.1",
"mantine-react-table": "2.0.0-beta.9", "mantine-react-table": "2.0.0-beta.9",
"next": "15.5.5", "next": "15.5.6",
"next-intl": "4.3.12", "next-intl": "4.3.12",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0" "react-dom": "19.2.0"
@@ -41,7 +41,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -947,27 +947,27 @@
}, },
"url": { "url": {
"label": "网址", "label": "网址",
"newLabel": "新网址" "newLabel": "新网址"
}, },
"opnsenseApiKey": { "opnsenseApiKey": {
"label": "API 密钥 (Key)", "label": "API 密钥 (密钥)",
"newLabel": "新 API 密钥 (Key)" "newLabel": "新API密钥(密钥)"
}, },
"opnsenseApiSecret": { "opnsenseApiSecret": {
"label": "API 密钥 (密)", "label": "API 密钥 (密)",
"newLabel": "新 API 密钥 (保密)" "newLabel": "新API密钥(机密)"
}, },
"githubAppId": { "githubAppId": {
"label": "应用ID", "label": "应用 ID",
"newLabel": "新应用ID" "newLabel": "新应用 ID"
}, },
"githubInstallationId": { "githubInstallationId": {
"label": "安装 ID", "label": "安装 Id",
"newLabel": "新安装 ID" "newLabel": "新安装ID"
}, },
"privateKey": { "privateKey": {
"label": "私人密钥", "label": "私钥",
"newLabel": "新私人密钥" "newLabel": "新私钥"
} }
} }
}, },
@@ -1148,8 +1148,8 @@
}, },
"unit": { "unit": {
"speed": { "speed": {
"kilometersPerHour": "", "kilometersPerHour": "千米/时",
"milesPerHour": "" "milesPerHour": "英里/时"
} }
} }
}, },
@@ -1164,7 +1164,7 @@
"label": "标题" "label": "标题"
}, },
"customCssClasses": { "customCssClasses": {
"label": "" "label": "自定义 CSS 类"
}, },
"borderColor": { "borderColor": {
"label": "边界颜色" "label": "边界颜色"
@@ -1295,20 +1295,20 @@
"label": "启用状态检查" "label": "启用状态检查"
}, },
"layout": { "layout": {
"label": "界面", "label": "布局",
"option": { "option": {
"row": "横向", "row": "水平",
"row-reverse": "横向 (反)", "row-reverse": "水平(反)",
"column": "垂直", "column": "垂直",
"column-reverse": "垂直 (反)" "column-reverse": "垂直(反)"
} }
}, },
"descriptionDisplayMode": { "descriptionDisplayMode": {
"label": "描述信息显示模式", "label": "描述显示模式",
"description": "选择应用描述的显示方式", "description": "选择应用描述的显示方式",
"option": { "option": {
"normal": "窗口组件内", "normal": "窗口组件内",
"tooltip": "显示为悬停提示", "tooltip": "在工具提示中显示",
"hidden": "隐藏" "hidden": "隐藏"
} }
} }
@@ -1558,8 +1558,8 @@
}, },
"placeholder": "开始写笔记", "placeholder": "开始写笔记",
"dismiss": { "dismiss": {
"title": "是否放弃改?", "title": "放弃改?",
"message": "有未保存的更改确定要丢弃吗?", "message": "您的笔记中有未保存的更改确定要放弃这些更改吗?",
"action": { "action": {
"discard": "放弃修改", "discard": "放弃修改",
"keepEditing": "继续编辑" "keepEditing": "继续编辑"
@@ -1717,7 +1717,7 @@
"name": "日历", "name": "日历",
"description": "在日历视图中显示某个相对时间段内的集成事件", "description": "在日历视图中显示某个相对时间段内的集成事件",
"duration": { "duration": {
"allDay": "" "allDay": "全天"
}, },
"option": { "option": {
"releaseType": { "releaseType": {
@@ -1754,7 +1754,7 @@
"description": "仅在当前天气时" "description": "仅在当前天气时"
}, },
"useImperialSpeed": { "useImperialSpeed": {
"label": "" "label": "风速使用英里/时"
}, },
"location": { "location": {
"label": "天气位置" "label": "天气位置"
@@ -1774,12 +1774,12 @@
"description": "日期应该是什么样的" "description": "日期应该是什么样的"
} }
}, },
"currentWindSpeed": "{currentWindSpeed} km/h", "currentWindSpeed": "{currentWindSpeed} {unit}",
"dailyForecast": { "dailyForecast": {
"sunrise": "日出", "sunrise": "日出",
"sunset": "日落", "sunset": "日落",
"maxWindSpeed": "最大风速:{maxWindSpeed} km/h", "maxWindSpeed": "最大风速:{maxWindSpeed} {unit}",
"maxWindGusts": "最大阵风:{maxWindGusts} km/h" "maxWindGusts": "最大阵风:{maxWindGusts} {unit}"
}, },
"kind": { "kind": {
"clear": "晴朗", "clear": "晴朗",
@@ -1994,21 +1994,21 @@
"name": "名称", "name": "名称",
"id": "ID", "id": "ID",
"metadata": { "metadata": {
"title": "", "title": "技术统计",
"video": { "video": {
"title": "", "title": "视频",
"resolution": "" "resolution": "分辨率"
}, },
"audio": { "audio": {
"title": "", "title": "音频",
"channelCount": "", "channelCount": "音频声道",
"codec": "" "codec": "音频编码"
}, },
"transcoding": { "transcoding": {
"title": "", "title": "转码",
"container": "", "container": "容器",
"resolution": "", "resolution": "分辨率",
"target": "" "target": "目标编解码器"
} }
} }
} }
@@ -2534,36 +2534,36 @@
}, },
"systemResources": { "systemResources": {
"name": "系统资源", "name": "系统资源",
"description": "", "description": "您系统的 CPU、内存、磁盘及其他硬件使用情况",
"option": { "option": {
"hasShadow": { "hasShadow": {
"label": "" "label": "启用图表阴影"
}, },
"visibleCharts": { "visibleCharts": {
"label": "", "label": "可见图表",
"description": "", "description": "选择您希望显示的图表。",
"option": { "option": {
"cpu": "", "cpu": "CPU",
"memory": "", "memory": "内存",
"network": "" "network": "网络"
} }
}, },
"labelDisplayMode": { "labelDisplayMode": {
"label": "", "label": "标签显示模式",
"option": { "option": {
"textWithIcon": "", "textWithIcon": "显示图标和文本",
"text": "", "text": "仅显示文本",
"icon": "", "icon": "仅显示图标",
"hidden": "" "hidden": "隐藏标签"
} }
} }
}, },
"card": { "card": {
"cpu": "CPU", "cpu": "CPU",
"memory": "", "memory": "内存",
"network": "", "network": "网络",
"up": "", "up": "上传",
"down": "" "down": "下载"
} }
} }
}, },
@@ -2987,8 +2987,8 @@
"integration": "集成", "integration": "集成",
"app": "应用", "app": "应用",
"group": "群组", "group": "群组",
"searchEngine": "", "searchEngine": "搜索引擎",
"media": "" "media": "媒体"
}, },
"statisticLabel": { "statisticLabel": {
"boards": "面板", "boards": "面板",
@@ -2997,8 +2997,8 @@
"authorization": "认证" "authorization": "认证"
}, },
"heroBanner": { "heroBanner": {
"title": "", "title": "欢迎回到您的",
"subtitle": "" "subtitle": "{app} 面板"
} }
}, },
"board": { "board": {
@@ -3356,7 +3356,7 @@
"label": "防火墙接口" "label": "防火墙接口"
}, },
"weather": { "weather": {
"label": "" "label": "天气"
} }
}, },
"interval": { "interval": {
@@ -3367,10 +3367,10 @@
"weeklyMonday": "每周一", "weeklyMonday": "每周一",
"update": { "update": {
"success": { "success": {
"message": "" "message": "间隔已成功更新"
}, },
"error": { "error": {
"message": "" "message": "更新间隔失败"
} }
} }
}, },
@@ -3385,49 +3385,49 @@
"label": "计划时间间隔" "label": "计划时间间隔"
}, },
"lastExecution": { "lastExecution": {
"label": "" "label": "上次执行"
}, },
"actions": { "actions": {
"label": "" "label": "操作"
} }
}, },
"table": { "table": {
"search": "" "search": "在 {count} 个任务中搜索……"
}, },
"action": { "action": {
"refresh": { "refresh": {
"label": "" "label": "刷新"
} }
}, },
"refresh": { "refresh": {
"success": { "success": {
"message": "" "message": "任务已成功刷新"
}, },
"error": { "error": {
"message": "" "message": "刷新任务失败"
} }
}, },
"trigger": { "trigger": {
"success": { "success": {
"message": "" "message": "任务已成功触发"
}, },
"error": { "error": {
"message": "" "message": "触发任务失败"
} }
}, },
"enable": { "enable": {
"success": { "success": {
"message": "" "message": "任务已成功启用"
} }
}, },
"disable": { "disable": {
"success": { "success": {
"message": "" "message": "任务已成功禁用"
} }
}, },
"toggle": { "toggle": {
"error": { "error": {
"message": "" "message": "切换任务状态失败"
} }
} }
}, },
@@ -3484,19 +3484,19 @@
"subtitle": "{count} 用于 Homarr 代码中" "subtitle": "{count} 用于 Homarr 代码中"
}, },
"hotkeys": { "hotkeys": {
"title": "", "title": "快捷键",
"subtitle": "", "subtitle": "用于增强工作流的键盘快捷键",
"field": { "field": {
"shortcut": "", "shortcut": "快捷键",
"action": "" "action": "操作"
}, },
"action": { "action": {
"toggleBoardEdit": "", "toggleBoardEdit": "切换面板编辑模式",
"toggleColorScheme": "", "toggleColorScheme": "切换 亮色/暗色 模式",
"saveNotebook": "", "saveNotebook": "保存笔记(仅在笔记小工具内有效)",
"openSpotlight": "" "openSpotlight": "打开搜索"
}, },
"note": "" "note": "提示Mod 键在 macOS 上指 Ctrl 键和 ⌘ 键"
} }
} }
} }

View File

@@ -1774,12 +1774,12 @@
"description": "Hvordan datoen skal se ud" "description": "Hvordan datoen skal se ud"
} }
}, },
"currentWindSpeed": "{currentWindSpeed} km/t", "currentWindSpeed": "{currentWindSpeed} {unit}",
"dailyForecast": { "dailyForecast": {
"sunrise": "Solopgang", "sunrise": "Solopgang",
"sunset": "Solnedgang", "sunset": "Solnedgang",
"maxWindSpeed": "Maks. vindhastighed: {maxWindSpeed} km/t", "maxWindSpeed": "Maks. vindhastighed: {maxWindSpeed} {unit}",
"maxWindGusts": "Maks. vindstød {maxWindGusts} km/t" "maxWindGusts": "Maks. vindstød {maxWindGusts} {unit}"
}, },
"kind": { "kind": {
"clear": "Skyfrit", "clear": "Skyfrit",

View File

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

View File

@@ -2997,8 +2997,8 @@
"authorization": "Autenticación" "authorization": "Autenticación"
}, },
"heroBanner": { "heroBanner": {
"title": "Bienvenido de nuevo a tu", "title": "Bienvenido de nuevo a...",
"subtitle": "Tablero de {app}" "subtitle": "{app} !!!"
} }
}, },
"board": { "board": {

View File

@@ -1754,7 +1754,7 @@
"description": "Solo sul meteo corrente" "description": "Solo sul meteo corrente"
}, },
"useImperialSpeed": { "useImperialSpeed": {
"label": "" "label": "Usa mph per la velocità del vento"
}, },
"location": { "location": {
"label": "Località meteo" "label": "Località meteo"
@@ -1996,18 +1996,18 @@
"metadata": { "metadata": {
"title": "", "title": "",
"video": { "video": {
"title": "", "title": "Video",
"resolution": "" "resolution": "Risoluzione"
}, },
"audio": { "audio": {
"title": "", "title": "Audio",
"channelCount": "", "channelCount": "Canali audio",
"codec": "" "codec": "Codec audio"
}, },
"transcoding": { "transcoding": {
"title": "", "title": "Transcodifica",
"container": "", "container": "Container",
"resolution": "", "resolution": "Risoluzione",
"target": "" "target": ""
} }
} }
@@ -2988,7 +2988,7 @@
"app": "Applicazioni", "app": "Applicazioni",
"group": "Gruppi", "group": "Gruppi",
"searchEngine": "", "searchEngine": "",
"media": "" "media": "Media"
}, },
"statisticLabel": { "statisticLabel": {
"boards": "Board", "boards": "Board",
@@ -3329,7 +3329,7 @@
"label": "" "label": ""
}, },
"updateChecker": { "updateChecker": {
"label": "" "label": "Controllo aggiornamenti"
}, },
"mediaTranscoding": { "mediaTranscoding": {
"label": "" "label": ""
@@ -3485,10 +3485,10 @@
}, },
"hotkeys": { "hotkeys": {
"title": "", "title": "",
"subtitle": "", "subtitle": "Scorciatoie da tastiera per migliorare il flusso di lavoro",
"field": { "field": {
"shortcut": "", "shortcut": "Scorciatoia",
"action": "" "action": "Azione"
}, },
"action": { "action": {
"toggleBoardEdit": "", "toggleBoardEdit": "",
@@ -3651,7 +3651,7 @@
"services": "Servizi", "services": "Servizi",
"pods": "Pods", "pods": "Pods",
"configmaps": "", "configmaps": "",
"secrets": "", "secrets": "Secrets",
"volumes": "Volumi" "volumes": "Volumi"
} }
}, },

View File

@@ -30,12 +30,12 @@
"@homarr/log": "workspace:^0.1.0", "@homarr/log": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "^8.3.4", "@mantine/core": "^8.3.5",
"@mantine/dates": "^8.3.4", "@mantine/dates": "^8.3.5",
"@mantine/hooks": "^8.3.4", "@mantine/hooks": "^8.3.5",
"@tabler/icons-react": "^3.35.0", "@tabler/icons-react": "^3.35.0",
"mantine-react-table": "2.0.0-beta.9", "mantine-react-table": "2.0.0-beta.9",
"next": "15.5.5", "next": "15.5.6",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"svgson": "^5.3.1" "svgson": "^5.3.1"
@@ -45,7 +45,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/css-modules": "^1.0.5", "@types/css-modules": "^1.0.5",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -31,7 +31,7 @@
"@homarr/eslint-config": "workspace:^0.2.0", "@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -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));

View File

@@ -48,9 +48,9 @@
"@homarr/translation": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0", "@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0",
"@mantine/charts": "^8.3.4", "@mantine/charts": "^8.3.5",
"@mantine/core": "^8.3.4", "@mantine/core": "^8.3.5",
"@mantine/hooks": "^8.3.4", "@mantine/hooks": "^8.3.5",
"@tabler/icons-react": "^3.35.0", "@tabler/icons-react": "^3.35.0",
"@tiptap/extension-color": "2.26.3", "@tiptap/extension-color": "2.26.3",
"@tiptap/extension-highlight": "2.26.3", "@tiptap/extension-highlight": "2.26.3",
@@ -72,7 +72,7 @@
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"mantine-form-zod-resolver": "^1.3.0", "mantine-form-zod-resolver": "^1.3.0",
"mantine-react-table": "2.0.0-beta.9", "mantine-react-table": "2.0.0-beta.9",
"next": "15.5.5", "next": "15.5.6",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@@ -85,7 +85,7 @@
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"@types/video.js": "^7.3.58", "@types/video.js": "^7.3.58",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }

View File

@@ -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>
); );
}; };

1274
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,7 @@
}, },
"prettier": "@homarr/prettier-config", "prettier": "@homarr/prettier-config",
"dependencies": { "dependencies": {
"@next/eslint-plugin-next": "15.5.5", "@next/eslint-plugin-next": "15.5.6",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-config-turbo": "^2.5.8", "eslint-config-turbo": "^2.5.8",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
@@ -29,7 +29,7 @@
"devDependencies": { "devDependencies": {
"@homarr/prettier-config": "workspace:^0.1.0", "@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0", "@homarr/tsconfig": "workspace:^0.1.0",
"eslint": "^9.37.0", "eslint": "^9.38.0",
"typescript": "^5.9.3" "typescript": "^5.9.3"
} }
} }