diff --git a/packages/api/src/router/onboard/onboard-router.ts b/packages/api/src/router/onboard/onboard-router.ts index 75cffb576..327a8f961 100644 --- a/packages/api/src/router/onboard/onboard-router.ts +++ b/packages/api/src/router/onboard/onboard-router.ts @@ -2,26 +2,97 @@ import superjson from "superjson"; import { z } from "zod/v4"; import { createId } from "@homarr/common"; +import { encryptSecret } from "@homarr/common/server"; +import type { Database } from "@homarr/db"; import { eq } from "@homarr/db"; -import { groups, integrationItems, itemLayouts, items, onboarding } from "@homarr/db/schema"; +import { + groups, + integrationItems, + integrations, + integrationSecrets, + itemLayouts, + items, + onboarding, +} from "@homarr/db/schema"; import { defaultBookmarkApps, defaultWidgetConfigs, emptySuperJSON, everyoneGroup, getWidgetKindsForIntegration, + integrationKinds, + integrationSecretKinds, onboardingSteps, } from "@homarr/definitions"; import type { WidgetKind } from "@homarr/definitions"; import { zodEnumFromArray } from "@homarr/validation/enums"; import { createTRPCRouter, onboardingProcedure, publicProcedure } from "../../trpc"; +import { MissingSecretError, testConnectionAsync } from "../integration/integration-test-connection"; +import { mapTestConnectionError } from "../integration/map-test-connection-error"; import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries"; const widgetConfigMap = new Map(defaultWidgetConfigs.map((config) => [config.kind, config])); const getWidgetConfig = (kind: WidgetKind) => widgetConfigMap.get(kind); +interface PlaceWidgetContext { + db: Database; + boardId: string; + sectionId: string; + layoutId: string; + columnCount: number; + xOffset: number; + yOffset: number; + rowMaxHeight: number; +} + +const placeWidgetAsync = async ( + ctx: PlaceWidgetContext, + kind: WidgetKind, + linkedIntegrationIds: string[], + options?: string, + size?: { width: number; height: number }, +) => { + const itemWidth = size?.width ?? 2; + const itemHeight = size?.height ?? 2; + + if (ctx.xOffset + itemWidth > ctx.columnCount) { + ctx.xOffset = 0; + ctx.yOffset += ctx.rowMaxHeight; + ctx.rowMaxHeight = 0; + } + + const itemId = createId(); + await ctx.db.insert(items).values({ + id: itemId, + boardId: ctx.boardId, + kind, + options: options ?? emptySuperJSON, + advancedOptions: emptySuperJSON, + }); + + await ctx.db.insert(itemLayouts).values({ + itemId, + sectionId: ctx.sectionId, + layoutId: ctx.layoutId, + xOffset: ctx.xOffset, + yOffset: ctx.yOffset, + width: itemWidth, + height: itemHeight, + }); + + for (const integrationId of linkedIntegrationIds) { + await ctx.db.insert(integrationItems).values({ + itemId, + integrationId, + }); + } + + ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight); + ctx.xOffset += itemWidth; +}; + export const onboardRouter = createTRPCRouter({ currentStep: publicProcedure.query(async ({ ctx }) => { return await getOnboardingOrFallbackAsync(ctx.db); @@ -29,6 +100,7 @@ export const onboardRouter = createTRPCRouter({ nextStep: publicProcedure .input( z.object({ + // Preferred step is only needed for 'preferred' conditions preferredStep: zodEnumFromArray(onboardingSteps).optional(), }), ) @@ -47,6 +119,61 @@ export const onboardRouter = createTRPCRouter({ step: "start", }); }), + createIntegration: onboardingProcedure + .requiresStep("integrations") + .input( + z.object({ + name: z.string().nonempty().max(127), + url: z + .string() + .url() + .regex(/^https?:\/\//), + kind: zodEnumFromArray(integrationKinds), + secrets: z.array( + z.object({ + kind: zodEnumFromArray(integrationSecretKinds), + value: z.string().nonempty(), + }), + ), + }), + ) + .mutation(async ({ ctx, input }) => { + const result = await testConnectionAsync({ + id: "new", + name: input.name, + url: input.url, + kind: input.kind, + secrets: input.secrets, + }).catch((error) => { + if (error instanceof MissingSecretError) { + return { success: false as const, error }; + } + throw error; + }); + + if (!result.success) { + return { error: mapTestConnectionError(result.error) }; + } + + const integrationId = createId(); + await ctx.db.insert(integrations).values({ + id: integrationId, + name: input.name, + url: input.url, + kind: input.kind, + appId: null, + }); + + if (input.secrets.length >= 1) { + await ctx.db.insert(integrationSecrets).values( + input.secrets.map((secret) => ({ + kind: secret.kind, + value: encryptSecret(secret.value), + integrationId, + })), + ); + } + }), setupIntegrations: onboardingProcedure.requiresStep("integrations").mutation(async ({ ctx }) => { const db = ctx.db; @@ -60,123 +187,86 @@ export const onboardRouter = createTRPCRouter({ }, }); - if (board) { - const everyoneGroupRow = await db.query.groups.findFirst({ - where: eq(groups.name, everyoneGroup), - }); - if (everyoneGroupRow && !everyoneGroupRow.homeBoardId) { - await db.update(groups).set({ homeBoardId: board.id }).where(eq(groups.id, everyoneGroupRow.id)); + if (!board) { + await nextOnboardingStepAsync(db, undefined); + return; + } + + const everyoneGroupRow = await db.query.groups.findFirst({ + where: eq(groups.name, everyoneGroup), + }); + if (everyoneGroupRow && !everyoneGroupRow.homeBoardId) { + await db.update(groups).set({ homeBoardId: board.id }).where(eq(groups.id, everyoneGroupRow.id)); + } + + const section = board.sections.find((boardSection) => boardSection.kind === "empty"); + const layout = board.layouts[0]; + + if (section && layout) { + const widgetCtx: PlaceWidgetContext = { + db, + boardId: board.id, + sectionId: section.id, + layoutId: layout.id, + columnCount: layout.columnCount, + xOffset: 0, + yOffset: 0, + rowMaxHeight: 0, + }; + + const placedWidgets = new Set(); + + for (const integration of allIntegrations) { + for (const widgetKind of getWidgetKindsForIntegration(integration.kind)) { + if (placedWidgets.has(widgetKind)) continue; + const config = getWidgetConfig(widgetKind); + if (config?.skip) continue; + placedWidgets.add(widgetKind); + + const matchingIds = allIntegrations + .filter((row) => getWidgetKindsForIntegration(row.kind).includes(widgetKind)) + .map((row) => row.id); + + const options = config?.options ? superjson.stringify(config.options) : undefined; + await placeWidgetAsync( + widgetCtx, + widgetKind, + matchingIds, + options, + config && { + width: config.width, + height: config.height, + }, + ); + } } - const section = board.sections.find((boardSection) => boardSection.kind === "empty"); - const layout = board.layouts[0]; + for (const app of allApps) { + const appOptions = superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }); + await placeWidgetAsync(widgetCtx, "app", [], appOptions, { width: 1, height: 1 }); + } - if (section && layout) { - const columnCount = layout.columnCount; - let xOffset = 0; - let yOffset = 0; - let rowMaxHeight = 0; + const bookmarkAppNames = new Set(defaultBookmarkApps.map((bookmark) => bookmark.name)); + const bookmarkAppIds = allApps.filter((app) => bookmarkAppNames.has(app.name)).map((app) => app.id); - const placeWidgetAsync = async ( - kind: WidgetKind, - linkedIntegrationIds: string[], - options?: string, - size?: { width: number; height: number }, - ) => { - const itemWidth = size?.width ?? 2; - const itemHeight = size?.height ?? 2; + for (const config of defaultWidgetConfigs) { + if (config.skip) continue; + if (placedWidgets.has(config.kind)) continue; + placedWidgets.add(config.kind); - if (xOffset + itemWidth > columnCount) { - xOffset = 0; - yOffset += rowMaxHeight; - rowMaxHeight = 0; - } + let options = config.options ? { ...config.options } : undefined; - const itemId = createId(); - await db.insert(items).values({ - id: itemId, - boardId: board.id, - kind, - options: options ?? emptySuperJSON, - advancedOptions: emptySuperJSON, - }); - - await db.insert(itemLayouts).values({ - itemId, - sectionId: section.id, - layoutId: layout.id, - xOffset, - yOffset, - width: itemWidth, - height: itemHeight, - }); - - for (const integrationId of linkedIntegrationIds) { - await db.insert(integrationItems).values({ - itemId, - integrationId, - }); - } - - rowMaxHeight = Math.max(rowMaxHeight, itemHeight); - xOffset += itemWidth; - }; - - const placedWidgets = new Set(); - - // Place integration-specific widgets - for (const integration of allIntegrations) { - const kind = integration.kind; - - for (const widgetKind of getWidgetKindsForIntegration(kind)) { - if (placedWidgets.has(widgetKind)) continue; - const config = getWidgetConfig(widgetKind); - if (config?.skip) continue; - placedWidgets.add(widgetKind); - - const matchingIds = allIntegrations - .filter((row) => getWidgetKindsForIntegration(row.kind).includes(widgetKind)) - .map((row) => row.id); - - const options = config?.options ? superjson.stringify(config.options) : undefined; - await placeWidgetAsync( - widgetKind, - matchingIds, - options, - config && { width: config.width, height: config.height }, - ); - } + if (config.kind === "bookmarks" && bookmarkAppIds.length > 0) { + options = { ...options, items: bookmarkAppIds }; } - // Place an app widget for each app in the DB - for (const app of allApps) { - const appOptions = superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }); - await placeWidgetAsync("app", [], appOptions, { width: 1, height: 1 }); - } - - // Place general widgets from config - const bookmarkAppNames = new Set(defaultBookmarkApps.map((bookmark) => bookmark.name)); - const bookmarkAppIds = allApps.filter((app) => bookmarkAppNames.has(app.name)).map((app) => app.id); - - for (const config of defaultWidgetConfigs) { - if (config.skip) continue; - if (placedWidgets.has(config.kind)) continue; - placedWidgets.add(config.kind); - - let options = config.options ? { ...config.options } : undefined; - - if (config.kind === "bookmarks" && bookmarkAppIds.length > 0) { - options = { ...options, items: bookmarkAppIds }; - } - - await placeWidgetAsync(config.kind, [], options ? superjson.stringify(options) : undefined, { - width: config.width, - height: config.height, - }); - } + await placeWidgetAsync(widgetCtx, config.kind, [], options ? superjson.stringify(options) : undefined, { + width: config.width, + height: config.height, + }); } } - await nextOnboardingStepAsync(db, "start"); + await nextOnboardingStepAsync(db, undefined); }), });