feat(integrations): add app linking (#4338)

This commit is contained in:
Meier Lukas
2025-10-24 20:21:27 +02:00
committed by GitHub
parent 6f0b5d7e04
commit 172db0e3f9
47 changed files with 6791 additions and 158 deletions

View File

@@ -32,6 +32,7 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
const integration = await ctx.db.query.integrations.findFirst({
where: and(eq(integrations.id, input.integrationId), inArray(integrations.kind, kinds)),
with: {
app: true,
secrets: true,
groupPermissions: true,
userPermissions: true,
@@ -65,6 +66,7 @@ export const createOneIntegrationMiddleware = <TKind extends IntegrationKind>(
ctx: {
integration: {
...rest,
externalUrl: rest.app?.href ?? null,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,
@@ -96,6 +98,7 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
? await ctx.db.query.integrations.findMany({
where: and(inArray(integrations.id, input.integrationIds), inArray(integrations.kind, kinds)),
with: {
app: true,
secrets: true,
items: {
with: {
@@ -125,6 +128,7 @@ export const createManyIntegrationMiddleware = <TKind extends IntegrationKind>(
integrations: dbIntegrations.map(
({ secrets, kind, items: _ignore1, groupPermissions: _ignore2, userPermissions: _ignore3, ...rest }) => ({
...rest,
externalUrl: rest.app?.href ?? null,
kind: kind as TKind,
decryptedSecrets: secrets.map((secret) => ({
...secret,

View File

@@ -118,20 +118,22 @@ export const appRouter = createTRPCRouter({
create: permissionRequiredProcedure
.requiresPermission("app-create")
.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 } })
.mutation(async ({ ctx, input }) => {
const id = createId();
await ctx.db.insert(apps).values({
const insertValues = {
id,
name: input.name,
description: input.description,
iconUrl: input.iconUrl,
href: input.href,
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
.requiresPermission("app-create")

View File

@@ -6,6 +6,7 @@ import { decryptSecret, encryptSecret } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { and, asc, eq, handleTransactionsAsync, inArray, like, or } from "@homarr/db";
import {
apps,
groupMembers,
groupPermissions,
integrationGroupPermissions,
@@ -212,6 +213,14 @@ export const integrationRouter = createTRPCRouter({
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,
updatedAt: secret.updatedAt,
})),
app: integration.app,
};
}),
create: permissionRequiredProcedure
@@ -245,6 +255,13 @@ export const integrationRouter = createTRPCRouter({
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({
id: "new",
name: input.name,
@@ -267,12 +284,15 @@ export const integrationRouter = createTRPCRouter({
};
}
const appId = await createAppIfNecessaryAsync(ctx.db, input.app);
const integrationId = createId();
await ctx.db.insert(integrations).values({
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
});
if (input.secrets.length >= 1) {
@@ -358,6 +378,7 @@ export const integrationRouter = createTRPCRouter({
.set({
name: input.name,
url: input.url,
appId: input.appId,
})
.where(eq(integrations.id, input.id));
@@ -652,3 +673,30 @@ const addSecretAsync = async (db: Database, input: AddSecretInput) => {
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 { logger } from "@homarr/log";
type FormIntegration = Integration & {
type FormIntegration = Omit<Integration, "appId"> & {
secrets: {
kind: IntegrationSecretKind;
value: string | null;
@@ -75,6 +75,7 @@ export const testConnectionAsync = async (
const integrationInstance = await createIntegrationAsync({
...baseIntegration,
decryptedSecrets,
externalUrl: null,
});
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 { createId } from "@homarr/common";
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 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 () => {
// Arrange
const db = createDb();
@@ -273,6 +369,36 @@ describe("create should create a new integration", () => {
// Assert
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", () => {
@@ -285,6 +411,7 @@ describe("update should update an integration", () => {
});
const lastWeek = new Date("2023-06-24T00:00:00Z");
const appId = createId();
const integrationId = createId();
const toInsert = {
id: integrationId,
@@ -293,6 +420,11 @@ describe("update should update an integration", () => {
url: "http://hole.local",
};
await db.insert(apps).values({
id: appId,
name: "Previous",
iconUrl: "logo.png",
});
await db.insert(integrations).values(toInsert);
const usernameToInsert = {
@@ -320,6 +452,7 @@ describe("update should update an integration", () => {
{ kind: "password" as const, value: null },
{ kind: "apiKey" as const, value: "1234567890" },
],
appId,
};
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!.kind).toBe(input.kind);
expect(dbIntegration!.url).toBe(input.url);
expect(dbIntegration!.appId).toBe(appId);
expect(dbSecrets.length).toBe(3);
const username = expectToBeDefined(dbSecrets.find((secret) => secret.kind === "username"));
@@ -365,6 +499,7 @@ describe("update should update an integration", () => {
name: "Pi Hole",
url: "http://hole.local",
secrets: [],
appId: null,
});
await expect(actAsync()).rejects.toThrow("Integration not found");
});
@@ -385,6 +520,7 @@ describe("update should update an integration", () => {
name: "Pi Hole",
url: "http://hole.local",
secrets: [],
appId: null,
});
// Assert

View File

@@ -55,6 +55,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
value: "secret",
}),
],
externalUrl: null,
});
});
@@ -104,6 +105,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
value: "dbSecret",
}),
],
externalUrl: null,
});
});
@@ -153,6 +155,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
value: "secret",
}),
],
externalUrl: null,
});
});
@@ -206,6 +209,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
value: "secret",
}),
],
externalUrl: null,
});
});
@@ -263,6 +267,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
value: "dbPassword",
}),
],
externalUrl: null,
});
});
@@ -336,6 +341,7 @@ describe("testConnectionAsync should run test connection of integration", () =>
value: "privateKey",
}),
],
externalUrl: null,
});
});
});

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,
"tag": "0035_increase-secret-kind-length",
"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,
"tag": "0001_increase-secret-kind-length",
"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`,
url: defaultUrl,
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,
"tag": "0033_add_cron_job_configuration",
"breakpoints": true
},
{
"idx": 34,
"version": "6",
"when": 1760968503571,
"tag": "0034_add_app_reference_to_integration",
"breakpoints": true
}
]
}

View File

@@ -16,6 +16,7 @@ export const getItemsWithIntegrationsAsync = async <TKind extends WidgetKind>(
with: {
integration: {
with: {
app: true,
secrets: {
columns: {
kind: true,

View File

@@ -199,6 +199,7 @@ export const integrations = mysqlTable(
name: text().notNull(),
url: text().notNull(),
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
},
(integrations) => ({
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),
items: many(integrationItems),
userPermissions: many(integrationUserPermissions),
groupPermissions: many(integrationGroupPermissions),
app: one(apps, {
fields: [integrations.appId],
references: [apps.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({

View File

@@ -198,6 +198,7 @@ export const integrations = pgTable(
name: text().notNull(),
url: text().notNull(),
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
},
(integrations) => ({
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),
items: many(integrationItems),
userPermissions: many(integrationUserPermissions),
groupPermissions: many(integrationGroupPermissions),
app: one(apps, {
fields: [integrations.appId],
references: [apps.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({

View File

@@ -185,6 +185,7 @@ export const integrations = sqliteTable(
name: text().notNull(),
url: text().notNull(),
kind: text().$type<IntegrationKind>().notNull(),
appId: text().references(() => apps.id, { onDelete: "set null" }),
},
(integrations) => ({
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),
items: many(integrationItems),
userPermissions: many(integrationUserPermissions),
groupPermissions: many(integrationGroupPermissions),
app: one(apps, {
fields: [integrations.appId],
references: [apps.id],
}),
}));
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({

View File

@@ -1,7 +1,4 @@
import { decryptSecret } from "@homarr/common/server";
import type { Modify } from "@homarr/common/types";
import type { Integration as DbIntegration } from "@homarr/db/schema";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-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>;
};
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;
// factories are an array, to differentiate in js between class constructors and functions

View File

@@ -17,6 +17,7 @@ export interface IntegrationInput {
id: string;
name: string;
url: string;
externalUrl: string | null;
decryptedSecrets: IntegrationSecret[];
}
@@ -54,8 +55,12 @@ export abstract class Integration {
return this.integration.decryptedSecrets.some((secret) => secret.kind === kind);
}
protected url(path: `/${string}`, queryParams?: Record<string, string | Date | number | boolean>) {
const baseUrl = removeTrailingSlash(this.integration.url);
private createUrl(
inputUrl: string,
path: `/${string}`,
queryParams?: Record<string, string | Date | number | boolean>,
) {
const baseUrl = removeTrailingSlash(inputUrl);
const url = new URL(`${baseUrl}${path}`);
if (queryParams) {
@@ -66,6 +71,13 @@ export abstract class Integration {
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> {
try {

View File

@@ -125,7 +125,7 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
sessionId: `${sessionInfo.Id}`,
sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`,
user: {
profilePictureUrl: super.url(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
profilePictureUrl: super.externalUrl(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
userId: sessionInfo.UserId ?? "",
username: sessionInfo.UserName ?? "",
},
@@ -169,13 +169,13 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
description: item.Overview,
releaseDate: item.PremiereDate ?? item.DateCreated,
imageUrls: {
poster: super.url(`/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(),
poster: super.externalUrl(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
backdrop: super.externalUrl(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
},
producer: item.Studios.at(0)?.Name,
rating: item.CommunityRating?.toFixed(1),
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";
// 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}`,
sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`,
user: {
profilePictureUrl: this.url(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
profilePictureUrl: this.externalUrl(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
userId: sessionInfo.UserId ?? "",
username: sessionInfo.UserName ?? "",
},
@@ -130,13 +130,13 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
releaseDate: new Date(item.PremiereDate ?? item.DateCreated!),
imageUrls: {
poster: super.url(`/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(),
poster: super.externalUrl(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
backdrop: super.externalUrl(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
},
producer: item.Studios?.at(0)?.Name ?? undefined,
rating: item.CommunityRating?.toFixed(1),
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>) => {
const links: CalendarLink[] = [
{
href: this.url(`/movie/${event.titleSlug}`).toString(),
href: this.externalUrl(`/movie/${event.titleSlug}`).toString(),
name: "Radarr",
logo: "/images/apps/radarr.svg",
color: undefined,

View File

@@ -74,7 +74,7 @@ export class ReadarrIntegration extends Integration implements ICalendarIntegrat
private getLinksForReadarrCalendarEvent = (event: z.infer<typeof readarrCalendarEventSchema>) => {
return [
{
href: this.url(`/author/${event.author.foreignAuthorId}`).toString(),
href: this.externalUrl(`/author/${event.author.foreignAuthorId}`).toString(),
color: "#f5c518",
isDark: false,
logo: "/images/apps/readarr.svg",
@@ -101,7 +101,7 @@ export class ReadarrIntegration extends Integration implements ICalendarIntegrat
if (!bestImage) {
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>) => {
const links: CalendarLink[] = [
{
href: this.url(`/series/${event.series.titleSlug}`).toString(),
href: this.externalUrl(`/series/${event.series.titleSlug}`).toString(),
name: "Sonarr",
logo: "/images/apps/sonarr.svg",
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",
links: [
{
href: this.url(
href: this.externalUrl(
`/apps/calendar/timeGridWeek/now/edit/sidebar/${eventSlug}/${dateInMillis / 1000}`,
).toString(),
name: "Nextcloud",

View File

@@ -54,7 +54,7 @@ export class NTFYIntegration extends Integration implements INotificationsIntegr
return notifications
.filter((notification) => notification !== null)
.map((notification): Notification => {
const topicURL = this.url(`/${notification.topic}`);
const topicURL = this.externalUrl(`/${notification.topic}`);
return {
id: notification.id,
time: new Date(notification.time * 1000),

View File

@@ -43,7 +43,7 @@ export class OverseerrIntegration
return schemaData.results.map((result) => ({
id: result.id,
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),
text: "overview" in result ? result.overview : undefined,
type: result.mediaType,
@@ -144,7 +144,7 @@ export class OverseerrIntegration
availability: request.media.status,
backdropImageUrl: `https://image.tmdb.org/t/p/original/${information.backdropPath}`,
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,
createdAt: request.createdAt,
airDate: new Date(information.airDate),
@@ -152,7 +152,7 @@ export class OverseerrIntegration
? ({
...request.requestedBy,
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(),
} satisfies Omit<RequestUser, "requestCount">)
: undefined,
@@ -180,7 +180,7 @@ export class OverseerrIntegration
return users.map((user): RequestUser => {
return {
...user,
link: this.url(`/users/${user.id}`).toString(),
link: this.externalUrl(`/users/${user.id}`).toString(),
avatar: this.constructAvatarUrl(user.avatar).toString(),
};
});
@@ -255,7 +255,7 @@ export class OverseerrIntegration
return avatar;
}
return this.url(`/${avatar}`);
return this.externalUrl(`/${avatar}`);
}
}

View File

@@ -135,6 +135,7 @@ const createAria2Intergration = (container: StartedTestContainer, apikey: string
],
name: "Aria2",
url: `http://${container.getHost()}:${container.getMappedPort(8080)}`,
externalUrl: null,
});
};

View File

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

View File

@@ -97,5 +97,6 @@ const createHomeAssistantIntegration = (container: StartedTestContainer, apiKeyO
],
name: "Home assistant",
url: `http://${container.getHost()}:${container.getMappedPort(8123)}`,
externalUrl: null,
});
};

View File

@@ -177,6 +177,7 @@ const createNzbGetIntegration = (container: StartedTestContainer, username: stri
],
name: "NzbGet",
url: `http://${container.getHost()}:${container.getMappedPort(6789)}`,
externalUrl: null,
});
};

View File

@@ -207,6 +207,7 @@ const createPiHoleIntegrationV5 = (container: StartedTestContainer, apiKey: stri
],
name: "Pi hole",
url: `http://${container.getHost()}:${container.getMappedPort(80)}`,
externalUrl: null,
});
};
@@ -233,5 +234,6 @@ const createPiHoleIntegrationV6 = (container: StartedTestContainer, apiKey: stri
],
name: "Pi hole",
url: `http://${container.getHost()}:${container.getMappedPort(80)}`,
externalUrl: null,
});
};

View File

@@ -218,6 +218,7 @@ const createSabnzbdIntegration = (container: StartedTestContainer, apiKey: strin
],
name: "Sabnzbd",
url: `http://${container.getHost()}:${container.getMappedPort(1212)}`,
externalUrl: null,
});
};

View File

@@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { Button, Card, Center, Grid, Input, Stack, Text } from "@mantine/core";
import { IconPlus, IconSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createModal, useModalAction } from "@homarr/modals";
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";
interface AppSelectModalProps {
onSelect?: (appId: string) => void;
onSelect?: (app: RouterOutputs["app"]["selectable"][number]) => void;
withCreate: boolean;
}
export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, innerProps }) => {
@@ -26,18 +28,18 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
[apps, search],
);
const handleSelect = (appId: string) => {
const handleSelect = (app: RouterOutputs["app"]["selectable"][number]) => {
if (innerProps.onSelect) {
innerProps.onSelect(appId);
innerProps.onSelect(app);
}
actions.closeModal();
};
const handleAddNewApp = () => {
openQuickAddAppModal({
onClose(createdAppId) {
onClose(app) {
if (innerProps.onSelect) {
innerProps.onSelect(createdAppId);
innerProps.onSelect(app);
}
actions.closeModal();
},
@@ -54,32 +56,34 @@ export const AppSelectModal = createModal<AppSelectModalProps>(({ actions, inner
data-autofocus
onKeyDown={(event) => {
if (event.key === "Enter" && filteredApps.length === 1 && filteredApps[0]) {
handleSelect(filteredApps[0].id);
handleSelect(filteredApps[0]);
}
}}
/>
<Grid>
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
<Card h="100%">
<Stack justify="space-between" h="100%">
<Stack gap="xs">
<Center>
<IconPlus size={24} />
</Center>
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
{t("app.action.create.title")}
</Text>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
{t("app.action.create.description")}
</Text>
{innerProps.withCreate && (
<Grid.Col span={{ xs: 12, sm: 4, md: 3 }}>
<Card h="100%">
<Stack justify="space-between" h="100%">
<Stack gap="xs">
<Center>
<IconPlus size={24} />
</Center>
<Text lh={1.2} style={{ whiteSpace: "normal" }} ta="center">
{t("app.action.create.title")}
</Text>
<Text lh={1.2} style={{ whiteSpace: "normal" }} size="xs" ta="center" c="dimmed">
{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>
<Button onClick={handleAddNewApp} variant="light" size="xs" mt="auto" radius="md" fullWidth>
{t("app.action.create.action")}
</Button>
</Stack>
</Card>
</Grid.Col>
</Card>
</Grid.Col>
)}
{filteredApps.map((app) => (
<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 ?? ""}
</Text>
</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 })}
</Button>
</Stack>

View File

@@ -1,5 +1,6 @@
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import type { MaybePromise } from "@homarr/common/types";
import { AppForm } from "@homarr/forms-collection";
@@ -9,7 +10,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { appManageSchema } from "@homarr/validation/app";
interface QuickAddAppModalProps {
onClose: (createdAppId: string) => MaybePromise<void>;
onClose: (createdApp: Omit<RouterOutputs["app"]["create"], "appId">) => MaybePromise<void>;
}
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>) => {
mutate(values, {
async onSuccess({ appId }) {
async onSuccess(app) {
showSuccessNotification({
title: tScoped("success.title"),
message: tScoped("success.message"),
});
await innerProps.onClose(appId);
await innerProps.onClose(app);
actions.closeModal();
},
});

View File

@@ -10,6 +10,7 @@ import { createCachedRequestHandler } from "./cached-request-handler";
type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"> & {
kind: TKind;
decryptedSecrets: Modify<Pick<IntegrationSecret, "kind" | "value">, { value: string }>[];
externalUrl: string | null;
};
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {

View File

@@ -96,6 +96,7 @@ export const createRequestIntegrationJobHandler = <
...integration,
kind: integration.kind as TIntegrationKind,
decryptedSecrets,
externalUrl: integration.app?.href ?? null,
},
input,
);

View File

@@ -645,6 +645,21 @@
"title": "Creation failed",
"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": {
@@ -658,6 +673,13 @@
"title": "Unable to apply changes",
"message": "The integration could not be saved"
}
},
"app": {
"action": {
"add": "Link an app",
"remove": "Unlink",
"select": "Select an app to link"
}
}
},
"delete": {
@@ -686,6 +708,9 @@
"label": "Create 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": {
"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."
@@ -1026,6 +1051,7 @@
"add": "Add",
"apply": "Apply",
"backToOverview": "Back to overview",
"change": "Change",
"create": "Create",
"createAnother": "Create and start over",
"edit": "Edit",

View File

@@ -2,6 +2,7 @@ import { z } from "zod/v4";
import { integrationKinds, integrationPermissions, integrationSecretKinds } from "@homarr/definitions";
import { appManageSchema } from "./app";
import { zodEnumFromArray } from "./enums";
import { createSavePermissionsSchema } from "./permissions";
@@ -19,6 +20,12 @@ export const integrationCreateSchema = z.object({
}),
),
attemptSearchEngineCreation: z.boolean(),
app: z
.object({
id: z.string(),
})
.or(appManageSchema)
.optional(),
});
export const integrationUpdateSchema = z.object({
@@ -31,6 +38,7 @@ export const integrationUpdateSchema = z.object({
value: z.string().nullable(),
}),
),
appId: z.string().nullable(),
});
export const integrationSavePermissionsSchema = createSavePermissionsSchema(zodEnumFromArray(integrationPermissions));

View File

@@ -8,6 +8,7 @@ import { IconCheck, IconRocket } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useModalAction } from "@homarr/modals";
import { QuickAddAppModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
@@ -21,6 +22,8 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
const tInput = useWidgetInputTranslation(kind, property);
const form = useFormContext();
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);
@@ -30,7 +33,7 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
);
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
label={tInput("label")}
searchable
@@ -59,22 +62,24 @@ export const WidgetAppInput = ({ property, kind }: CommonWidgetInputProps<"app">
styles={{ root: { flex: "1" } }}
{...form.getInputProps(`options.${property}`)}
/>
<Button
mt={3}
rightSection={<IconRocket size="1.5rem" />}
variant="default"
onClick={() =>
openModal({
// eslint-disable-next-line no-restricted-syntax
async onClose(createdAppId) {
await refetch();
form.setFieldValue(`options.${property}`, createdAppId);
},
})
}
>
{t("widget.common.app.quickCreate")}
</Button>
{canCreateApps && (
<Button
mt={3}
rightSection={<IconRocket size="1.5rem" />}
variant="default"
onClick={() =>
openModal({
// eslint-disable-next-line no-restricted-syntax
async onClose(createdAppId) {
await refetch();
form.setFieldValue(`options.${property}`, createdAppId);
},
})
}
>
{t("widget.common.app.quickCreate")}
</Button>
)}
</SimpleGrid>
);
};