fix: harden onboarding completion
This commit is contained in:
1 parent
576cc0b418
commit
07770e862c
44 files changed
+3003
-1000
No files matched your search
@@ -8,7 +8,7 @@ import type { GridAlgorithmItem } from "@homarr/common";
|
||||
import type { DeviceType } from "@homarr/common/server";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
|
||||
import { and, asc, eq, handleTransactionsAsync, inArray, isNull, like, not, or, sql } from "@homarr/db";
|
||||
import { and, asc, eq, gt, gte, handleTransactionsAsync, inArray, isNull, like, lt, not, or, sql } from "@homarr/db";
|
||||
import { createDbInsertCollectionWithoutTransaction } from "@homarr/db/collection";
|
||||
import { seedProtectedBoardLayoutsAsync } from "@homarr/db/migrations/seed";
|
||||
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
|
||||
@@ -84,6 +84,7 @@ interface BoardItemPlacementRectangle {
|
||||
}
|
||||
|
||||
const boardItemPlacementTails = new Map<string, Promise<void>>();
|
||||
const maxManageOverviewPreviewRows = 12;
|
||||
|
||||
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
|
||||
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
|
||||
@@ -261,6 +262,11 @@ export const boardRouter = createTRPCRouter({
|
||||
breakpoint: true,
|
||||
role: true,
|
||||
},
|
||||
orderBy: (layout, { desc, sql }) => [
|
||||
sql`CASE WHEN ${layout.role} = 'base' THEN 0 ELSE 1 END`,
|
||||
desc(layout.breakpoint),
|
||||
],
|
||||
limit: 1,
|
||||
},
|
||||
sections: {
|
||||
columns: {
|
||||
@@ -268,26 +274,21 @@ export const boardRouter = createTRPCRouter({
|
||||
kind: true,
|
||||
xOffset: true,
|
||||
},
|
||||
with: {
|
||||
layouts: {
|
||||
columns: {
|
||||
layoutId: true,
|
||||
parentSectionId: true,
|
||||
xOffset: true,
|
||||
yOffset: true,
|
||||
width: true,
|
||||
height: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: eq(sections.kind, "empty"),
|
||||
orderBy: (section, { asc }) => [asc(section.xOffset), asc(section.id)],
|
||||
},
|
||||
items: {
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
with: {
|
||||
layouts: {
|
||||
},
|
||||
where: getAccessibleBoardsWhere(ctx.session?.user.permissions.includes("board-view-all"), userId, boardIds),
|
||||
});
|
||||
|
||||
const previewLayoutIds = dbBoards.flatMap((board) => board.layouts.map((layout) => layout.id));
|
||||
const rootSectionIds = dbBoards.flatMap((board) => board.sections.map((section) => section.id));
|
||||
const [previewItemLayouts, previewSectionLayouts] =
|
||||
previewLayoutIds.length > 0 && rootSectionIds.length > 0
|
||||
? await Promise.all([
|
||||
ctx.db.query.itemLayouts.findMany({
|
||||
columns: {
|
||||
itemId: true,
|
||||
layoutId: true,
|
||||
sectionId: true,
|
||||
xOffset: true,
|
||||
@@ -295,17 +296,93 @@ export const boardRouter = createTRPCRouter({
|
||||
width: true,
|
||||
height: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: getAccessibleBoardsWhere(ctx.session?.user.permissions.includes("board-view-all"), userId, boardIds),
|
||||
});
|
||||
where: and(
|
||||
inArray(itemLayouts.layoutId, previewLayoutIds),
|
||||
inArray(itemLayouts.sectionId, rootSectionIds),
|
||||
gte(itemLayouts.xOffset, 0),
|
||||
gte(itemLayouts.yOffset, 0),
|
||||
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
gt(itemLayouts.width, 0),
|
||||
gt(itemLayouts.height, 0),
|
||||
),
|
||||
}),
|
||||
ctx.db.query.sectionLayouts.findMany({
|
||||
columns: {
|
||||
sectionId: true,
|
||||
layoutId: true,
|
||||
parentSectionId: true,
|
||||
xOffset: true,
|
||||
yOffset: true,
|
||||
width: true,
|
||||
height: true,
|
||||
},
|
||||
with: {
|
||||
section: {
|
||||
columns: { kind: true },
|
||||
},
|
||||
},
|
||||
where: and(
|
||||
inArray(sectionLayouts.layoutId, previewLayoutIds),
|
||||
inArray(sectionLayouts.parentSectionId, rootSectionIds),
|
||||
gte(sectionLayouts.xOffset, 0),
|
||||
gte(sectionLayouts.yOffset, 0),
|
||||
lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
|
||||
gt(sectionLayouts.width, 0),
|
||||
gt(sectionLayouts.height, 0),
|
||||
),
|
||||
}),
|
||||
])
|
||||
: [[], []];
|
||||
|
||||
return dbBoards.map(({ layouts: boardLayouts, sections: boardSections, items: boardItems, ...board }) => {
|
||||
const previewLayout =
|
||||
boardLayouts.find((layout) => layout.role === "base") ??
|
||||
boardLayouts.toSorted((first, second) => second.breakpoint - first.breakpoint).at(0);
|
||||
return dbBoards.map(({ layouts: boardLayouts, sections: boardSections, ...board }) => {
|
||||
const previewLayout = boardLayouts.at(0);
|
||||
|
||||
const rootsByLane = new Map<BoardLane, (typeof boardSections)[number]>();
|
||||
for (const section of boardSections) {
|
||||
const lane = getRootSectionLane(section.xOffset);
|
||||
if (!rootsByLane.has(lane)) rootsByLane.set(lane, section);
|
||||
}
|
||||
const previewRoots = boardLanes.flatMap((lane) => {
|
||||
const root = rootsByLane.get(lane);
|
||||
return root ? [{ id: root.id, kind: "empty" as const, xOffset: root.xOffset, layouts: [] }] : [];
|
||||
});
|
||||
|
||||
const rootColumnCountById = new Map(
|
||||
boardLanes.flatMap((lane) => {
|
||||
const root = rootsByLane.get(lane);
|
||||
return root && previewLayout ? [[root.id, getBoardLaneColumnCount(previewLayout, lane)] as const] : [];
|
||||
}),
|
||||
);
|
||||
const isInsideRootLane = (layout: { sectionId: string; xOffset: number }) => {
|
||||
const columnCount = rootColumnCountById.get(layout.sectionId);
|
||||
return columnCount !== undefined && columnCount > 0 && layout.xOffset < columnCount;
|
||||
};
|
||||
const isInsideParentRootLane = (layout: { parentSectionId: string | null; xOffset: number }) => {
|
||||
if (!layout.parentSectionId) return false;
|
||||
const columnCount = rootColumnCountById.get(layout.parentSectionId);
|
||||
return columnCount !== undefined && columnCount > 0 && layout.xOffset < columnCount;
|
||||
};
|
||||
|
||||
const itemPreview = previewLayout
|
||||
? previewItemLayouts
|
||||
.filter((layout) => layout.layoutId === previewLayout.id && isInsideRootLane(layout))
|
||||
.map((layout) => ({ id: layout.itemId, layouts: [layout] }))
|
||||
: [];
|
||||
const containerPreview = previewLayout
|
||||
? previewSectionLayouts
|
||||
.filter(
|
||||
(layout) =>
|
||||
layout.layoutId === previewLayout.id &&
|
||||
layout.section.kind === "container" &&
|
||||
isInsideParentRootLane(layout),
|
||||
)
|
||||
.map((layout) => ({
|
||||
id: layout.sectionId,
|
||||
kind: "container" as const,
|
||||
xOffset: null,
|
||||
layouts: [layout],
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
...board,
|
||||
@@ -314,14 +391,8 @@ export const boardRouter = createTRPCRouter({
|
||||
preview: previewLayout
|
||||
? {
|
||||
layouts: [previewLayout],
|
||||
sections: boardSections.map((section) => ({
|
||||
...section,
|
||||
layouts: section.layouts.filter((layout) => layout.layoutId === previewLayout.id),
|
||||
})),
|
||||
items: boardItems.map((item) => ({
|
||||
...item,
|
||||
layouts: item.layouts.filter((layout) => layout.layoutId === previewLayout.id),
|
||||
})),
|
||||
sections: [...previewRoots, ...containerPreview],
|
||||
items: itemPreview,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { MySqlRawQueryResult } from "drizzle-orm/mysql2";
|
||||
import type { QueryResult } from "pg";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
@@ -193,16 +195,32 @@ export const groupRouter = createTRPCRouter({
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
const transitionResult = (await transaction
|
||||
.update(schema.onboarding)
|
||||
.set({ previousStep: "group", step: "setup" })
|
||||
.where(eq(schema.onboarding.step, "group"))) as MySqlRawQueryResult | QueryResult;
|
||||
const transitionedRows = Array.isArray(transitionResult)
|
||||
? transitionResult[0].affectedRows
|
||||
: (transitionResult.rowCount ?? 0);
|
||||
if (transitionedRows !== 1) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "The initial external group was already created." });
|
||||
}
|
||||
await transaction.insert(schema.groups).values(groupRow);
|
||||
await transaction.insert(schema.groupPermissions).values({ groupId, permission: "admin" });
|
||||
await transaction.update(schema.onboarding).set({ previousStep: "group", step: "setup" });
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
const transitionResult = transaction
|
||||
.update(onboarding)
|
||||
.set({ previousStep: "group", step: "setup" })
|
||||
.where(eq(onboarding.step, "group"))
|
||||
.run();
|
||||
if (transitionResult.changes !== 1) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "The initial external group was already created." });
|
||||
}
|
||||
transaction.insert(groups).values(groupRow).run();
|
||||
transaction.insert(groupPermissions).values({ groupId, permission: "admin" }).run();
|
||||
transaction.update(onboarding).set({ previousStep: "group", step: "setup" }).run();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { probeRuntimeCapabilitiesAsync } from "./capability-probes";
|
||||
|
||||
describe("probeRuntimeCapabilitiesAsync", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("reports independently reachable runtime capabilities", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response("{}", { status: 200 }));
|
||||
|
||||
await expect(
|
||||
probeRuntimeCapabilitiesAsync({
|
||||
kubernetesEnabled: true,
|
||||
workshopApiUrl: "https://workshop.example.com/",
|
||||
fetchImpl,
|
||||
getKubernetesVersionAsync: async () => ({ gitVersion: "v1.34.0" }),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kubernetes: { status: "available", detail: "v1.34.0" },
|
||||
workshop: { status: "available" },
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://workshop.example.com/api/health",
|
||||
expect.objectContaining({ headers: { Accept: "application/json" } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("distinguishes disabled and unreachable capabilities", async () => {
|
||||
await expect(
|
||||
probeRuntimeCapabilitiesAsync({
|
||||
kubernetesEnabled: false,
|
||||
workshopApiUrl: "https://workshop.example.com",
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 503 })),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kubernetes: { status: "disabled" },
|
||||
workshop: { status: "unavailable" },
|
||||
});
|
||||
});
|
||||
|
||||
it("aborts a never-resolving Kubernetes request when the probe times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
let kubernetesSignal: AbortSignal | undefined;
|
||||
const getKubernetesVersionAsync = vi.fn(
|
||||
async (signal: AbortSignal) =>
|
||||
await new Promise<never>((_, reject) => {
|
||||
kubernetesSignal = signal;
|
||||
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
||||
}),
|
||||
);
|
||||
|
||||
const result = probeRuntimeCapabilitiesAsync({
|
||||
kubernetesEnabled: true,
|
||||
workshopApiUrl: "https://workshop.example.com",
|
||||
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 200 })),
|
||||
getKubernetesVersionAsync,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
|
||||
await expect(result).resolves.toEqual({
|
||||
kubernetes: { status: "unavailable" },
|
||||
workshop: { status: "available" },
|
||||
});
|
||||
expect(getKubernetesVersionAsync).toHaveBeenCalledOnce();
|
||||
expect(kubernetesSignal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { RequestContext, ResponseContext } from "@kubernetes/client-node";
|
||||
|
||||
import { KubernetesClient } from "../kubernetes/kubernetes-client";
|
||||
|
||||
export type RuntimeCapability =
|
||||
| { status: "available"; detail?: string }
|
||||
| { status: "disabled" }
|
||||
| { status: "unavailable" };
|
||||
|
||||
export interface RuntimeCapabilities {
|
||||
kubernetes: RuntimeCapability;
|
||||
workshop: RuntimeCapability;
|
||||
}
|
||||
|
||||
const capabilityProbeTimeoutMs = 5_000;
|
||||
|
||||
const withTimeoutAsync = async <T>(
|
||||
operation: (signal: AbortSignal) => Promise<T>,
|
||||
timeoutMs = capabilityProbeTimeoutMs,
|
||||
) => {
|
||||
const controller = new AbortController();
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation(controller.signal),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
const error = new Error("Capability probe timed out");
|
||||
controller.abort(error);
|
||||
reject(error);
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
export const probeRuntimeCapabilitiesAsync = async ({
|
||||
kubernetesEnabled,
|
||||
workshopApiUrl,
|
||||
fetchImpl = fetch,
|
||||
getKubernetesVersionAsync = async (signal) =>
|
||||
await KubernetesClient.getInstance().versionApi.getCode({
|
||||
middleware: [
|
||||
{
|
||||
pre: async (request: RequestContext) => {
|
||||
request.setSignal(signal);
|
||||
return request;
|
||||
},
|
||||
post: async (response: ResponseContext) => response,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}: {
|
||||
kubernetesEnabled: boolean;
|
||||
workshopApiUrl: string;
|
||||
fetchImpl?: typeof fetch;
|
||||
getKubernetesVersionAsync?: (signal: AbortSignal) => Promise<{ gitVersion?: string }>;
|
||||
}): Promise<RuntimeCapabilities> => {
|
||||
const kubernetesPromise: Promise<RuntimeCapability> = kubernetesEnabled
|
||||
? withTimeoutAsync(getKubernetesVersionAsync)
|
||||
.then((version) => ({ status: "available" as const, detail: version.gitVersion }))
|
||||
.catch(() => ({ status: "unavailable" as const }))
|
||||
: Promise.resolve({ status: "disabled" as const });
|
||||
const workshopPromise: Promise<RuntimeCapability> = withTimeoutAsync(
|
||||
async (signal) =>
|
||||
await fetchImpl(`${workshopApiUrl.replace(/\/+$/u, "")}/api/health`, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
}).then((response) => {
|
||||
if (!response.ok) throw new Error(`Workshop health returned ${response.status}`);
|
||||
}),
|
||||
)
|
||||
.then(() => ({ status: "available" as const }))
|
||||
.catch(() => ({ status: "unavailable" as const }));
|
||||
|
||||
const [kubernetes, workshop] = await Promise.all([kubernetesPromise, workshopPromise]);
|
||||
return { kubernetes, workshop };
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import type { OnboardingStep } from "@homarr/definitions";
|
||||
export const nextOnboardingStepAsync = async (db: Database) => {
|
||||
const { current } = await getOnboardingOrFallbackAsync(db);
|
||||
if (current !== "start") return;
|
||||
const nextStep = getNextOnboardingStep();
|
||||
const nextStep = await getNextOnboardingStepAsync(db);
|
||||
if (!nextStep) return;
|
||||
|
||||
await db.update(onboarding).set({ previousStep: current, step: nextStep });
|
||||
@@ -22,8 +22,9 @@ export const getOnboardingOrFallbackAsync = async (db: Database) => {
|
||||
};
|
||||
};
|
||||
|
||||
const getNextOnboardingStep = (): OnboardingStep => {
|
||||
if (isProviderEnabled("credentials")) return "user";
|
||||
const getNextOnboardingStepAsync = async (db: Database): Promise<OnboardingStep> => {
|
||||
const existingUser = await db.query.users.findFirst({ columns: { id: true } });
|
||||
if (isProviderEnabled("credentials") && !existingUser) return "user";
|
||||
if (isProviderEnabled("ldap") || isProviderEnabled("oidc")) return "group";
|
||||
return "setup";
|
||||
};
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { MySqlRawQueryResult } from "drizzle-orm/mysql2";
|
||||
import type { QueryResult } from "pg";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import SuperJSON from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
@@ -25,34 +27,28 @@ import {
|
||||
serverSettings,
|
||||
} from "@homarr/db/schema";
|
||||
import {
|
||||
defaultWidgetConfigs,
|
||||
emptySuperJSON,
|
||||
everyoneGroup,
|
||||
extractContainerImageName,
|
||||
getBoardLaneColumnCount,
|
||||
getDefaultWidgetConfig,
|
||||
getIconUrl,
|
||||
getRootSectionLane,
|
||||
getWidgetIntegrationIssue,
|
||||
getWidgetKindsForIntegration,
|
||||
integrationKinds,
|
||||
integrationSecretKinds,
|
||||
matchIntegrationKindFromContainer,
|
||||
rootSectionOffsets,
|
||||
widgetIntegrationLimits,
|
||||
} from "@homarr/definitions";
|
||||
import type { IntegrationKind, OnboardingLayoutPreset, WidgetKind } from "@homarr/definitions";
|
||||
import {
|
||||
onboardingCompleteSetupSchema,
|
||||
onboardingCreateIntegrationSchema,
|
||||
onboardingDiscoveredAppSchema,
|
||||
} from "@homarr/validation/onboarding";
|
||||
import { zodEnumFromArray } from "@homarr/validation/enums";
|
||||
import { onboardingCompleteSetupSchema } from "@homarr/validation/onboarding";
|
||||
|
||||
import { onboardingClaimSettingKey } from "../../onboarding-claim";
|
||||
import { env } from "../../env";
|
||||
import { createTRPCRouter, onboardingClaimedProcedure, 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";
|
||||
import { probeRuntimeCapabilitiesAsync } from "./capability-probes";
|
||||
|
||||
interface PortInfo {
|
||||
IP?: string;
|
||||
@@ -125,8 +121,17 @@ const getLayoutPresetColumnCount = (preset: OnboardingLayoutPreset) => {
|
||||
};
|
||||
|
||||
const normalizeLabelBoardName = (name: string) => name.replace(/[^A-Za-z0-9-_]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
const defaultAppIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
|
||||
|
||||
const defaultWidgetConfigByKind = new Map(defaultWidgetConfigs.map((config) => [config.kind, config]));
|
||||
const runtimeCapabilitySchema = z.discriminatedUnion("status", [
|
||||
z.object({ status: z.literal("available"), detail: z.string().optional() }),
|
||||
z.object({ status: z.literal("disabled") }),
|
||||
z.object({ status: z.literal("unavailable") }),
|
||||
]);
|
||||
const runtimeCapabilitiesSchema = z.object({
|
||||
kubernetes: runtimeCapabilitySchema,
|
||||
workshop: runtimeCapabilitySchema,
|
||||
});
|
||||
|
||||
export const onboardRouter = createTRPCRouter({
|
||||
currentStep: publicProcedure.query(async ({ ctx }) => await getOnboardingOrFallbackAsync(ctx.db)),
|
||||
@@ -139,102 +144,15 @@ export const onboardRouter = createTRPCRouter({
|
||||
await nextOnboardingStepAsync(ctx.db);
|
||||
}),
|
||||
|
||||
createIntegration: onboardingProcedure
|
||||
detectRuntimeCapabilities: onboardingProcedure
|
||||
.requiresStep("setup")
|
||||
.input(
|
||||
onboardingCreateIntegrationSchema.extend({
|
||||
kind: zodEnumFromArray(integrationKinds),
|
||||
secrets: z.array(
|
||||
z.object({
|
||||
kind: zodEnumFromArray(integrationSecretKinds),
|
||||
value: z.string().nonempty(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const testResult = await testConnectionAsync({
|
||||
id: "new",
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind,
|
||||
secrets: input.secrets,
|
||||
}).catch((error) => {
|
||||
if (!(error instanceof MissingSecretError)) throw error;
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: error.message });
|
||||
.output(runtimeCapabilitiesSchema)
|
||||
.query(async () => {
|
||||
const { env: dockerEnv } = await import("@homarr/docker/env");
|
||||
return await probeRuntimeCapabilitiesAsync({
|
||||
kubernetesEnabled: dockerEnv.ENABLE_KUBERNETES,
|
||||
workshopApiUrl: env.WORKSHOP_API_URL ?? env.HOMARR_WEBSITE_URL,
|
||||
});
|
||||
|
||||
if (!testResult.success) return { error: mapTestConnectionError(testResult.error) };
|
||||
|
||||
const integrationId = input.sourceId ? stableOnboardingId("integration", input.sourceId) : createId();
|
||||
const existingIntegration = await ctx.db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, integrationId),
|
||||
});
|
||||
const appId =
|
||||
existingIntegration?.appId ?? (input.sourceId ? stableOnboardingId("app", input.sourceId) : createId());
|
||||
const existingApp = await ctx.db.query.apps.findFirst({ where: eq(apps.id, appId) });
|
||||
const appRow = {
|
||||
id: appId,
|
||||
name: input.name,
|
||||
iconUrl: input.iconUrl ?? getIconUrl(input.kind),
|
||||
href: input.url,
|
||||
pingUrl: input.pingUrl ?? input.url,
|
||||
description: input.description ?? null,
|
||||
};
|
||||
const integrationRow = {
|
||||
id: integrationId,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
kind: input.kind,
|
||||
appId,
|
||||
};
|
||||
const secretRows = input.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: encryptSecret(secret.value),
|
||||
integrationId,
|
||||
}));
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
if (existingApp) {
|
||||
await transaction.update(schema.apps).set(appRow).where(eq(schema.apps.id, appId));
|
||||
} else {
|
||||
await transaction.insert(schema.apps).values(appRow);
|
||||
}
|
||||
if (existingIntegration) {
|
||||
await transaction
|
||||
.update(schema.integrations)
|
||||
.set(integrationRow)
|
||||
.where(eq(schema.integrations.id, integrationId));
|
||||
await transaction
|
||||
.delete(schema.integrationSecrets)
|
||||
.where(eq(schema.integrationSecrets.integrationId, integrationId));
|
||||
} else {
|
||||
await transaction.insert(schema.integrations).values(integrationRow);
|
||||
}
|
||||
if (secretRows.length > 0) await transaction.insert(schema.integrationSecrets).values(secretRows);
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
if (existingApp) {
|
||||
transaction.update(apps).set(appRow).where(eq(apps.id, appId)).run();
|
||||
} else {
|
||||
transaction.insert(apps).values(appRow).run();
|
||||
}
|
||||
if (existingIntegration) {
|
||||
transaction.update(integrations).set(integrationRow).where(eq(integrations.id, integrationId)).run();
|
||||
transaction.delete(integrationSecrets).where(eq(integrationSecrets.integrationId, integrationId)).run();
|
||||
} else {
|
||||
transaction.insert(integrations).values(integrationRow).run();
|
||||
}
|
||||
if (secretRows.length > 0) transaction.insert(integrationSecrets).values(secretRows).run();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return { id: integrationId, appId };
|
||||
}),
|
||||
|
||||
discoverDockerServices: onboardingProcedure.requiresStep("setup").query(async ({ ctx }) => {
|
||||
@@ -372,72 +290,6 @@ export const onboardRouter = createTRPCRouter({
|
||||
}
|
||||
}),
|
||||
|
||||
createAppsFromDiscovery: onboardingProcedure
|
||||
.requiresStep("setup")
|
||||
.input(z.array(onboardingDiscoveredAppSchema).max(128))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
|
||||
const planned = [] as Array<{
|
||||
id: string;
|
||||
sourceId?: string;
|
||||
existing: boolean;
|
||||
row: typeof apps.$inferInsert;
|
||||
}>;
|
||||
const seenIds = new Set<string>();
|
||||
|
||||
for (const app of input) {
|
||||
const stableId = app.sourceId ? stableOnboardingId("app", app.sourceId) : undefined;
|
||||
const existing = stableId
|
||||
? await ctx.db.query.apps.findFirst({ where: eq(apps.id, stableId) })
|
||||
: app.href
|
||||
? await ctx.db.query.apps.findFirst({ where: eq(apps.href, app.href) })
|
||||
: undefined;
|
||||
const id = existing?.id ?? stableId ?? createId();
|
||||
if (seenIds.has(id)) continue;
|
||||
seenIds.add(id);
|
||||
planned.push({
|
||||
id,
|
||||
sourceId: app.sourceId,
|
||||
existing: Boolean(existing),
|
||||
row: {
|
||||
id,
|
||||
name: app.name,
|
||||
iconUrl: app.iconUrl ?? defaultIcon,
|
||||
href: app.href,
|
||||
pingUrl: app.pingUrl ?? app.href,
|
||||
description: app.description ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
for (const app of planned) {
|
||||
if (app.existing) {
|
||||
await transaction.update(schema.apps).set(app.row).where(eq(schema.apps.id, app.id));
|
||||
} else {
|
||||
await transaction.insert(schema.apps).values(app.row);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
for (const app of planned) {
|
||||
if (app.existing) {
|
||||
transaction.update(apps).set(app.row).where(eq(apps.id, app.id)).run();
|
||||
} else {
|
||||
transaction.insert(apps).values(app.row).run();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return { apps: planned.map(({ id, sourceId }) => ({ id, sourceId })) };
|
||||
}),
|
||||
|
||||
completeSetup: onboardingProcedure
|
||||
.requiresStep("setup")
|
||||
.input(onboardingCompleteSetupSchema)
|
||||
@@ -518,20 +370,114 @@ export const onboardRouter = createTRPCRouter({
|
||||
message: "Select the exact board to configure when more than one board exists.",
|
||||
});
|
||||
}
|
||||
const selectedIntegrations =
|
||||
|
||||
await Promise.all(
|
||||
input.integrations.map(async (integration) => {
|
||||
const result = await testConnectionAsync({
|
||||
id: stableOnboardingId("integration", integration.sourceId),
|
||||
name: integration.name,
|
||||
url: integration.url,
|
||||
kind: integration.kind,
|
||||
secrets: integration.secrets,
|
||||
}).catch((error) => {
|
||||
if (!(error instanceof MissingSecretError)) throw error;
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: error.message });
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Unable to connect to ${integration.name}: ${result.error.message}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const plannedIntegrationIds = input.integrations.map((integration) =>
|
||||
stableOnboardingId("integration", integration.sourceId),
|
||||
);
|
||||
const existingPlannedIntegrations =
|
||||
plannedIntegrationIds.length > 0
|
||||
? await ctx.db.query.integrations.findMany({ where: inArray(integrations.id, plannedIntegrationIds) })
|
||||
: [];
|
||||
const existingIntegrationById = new Map(
|
||||
existingPlannedIntegrations.map((integration) => [integration.id, integration]),
|
||||
);
|
||||
const integrationPlans = input.integrations.map((integration) => {
|
||||
const id = stableOnboardingId("integration", integration.sourceId);
|
||||
const existing = existingIntegrationById.get(id);
|
||||
const appId = existing?.appId ?? stableOnboardingId("app", integration.sourceId);
|
||||
return {
|
||||
id,
|
||||
existing: existing !== undefined,
|
||||
row: {
|
||||
id,
|
||||
name: integration.name,
|
||||
url: integration.url,
|
||||
kind: integration.kind,
|
||||
appId,
|
||||
} satisfies typeof integrations.$inferSelect,
|
||||
appRow: {
|
||||
id: appId,
|
||||
name: integration.name,
|
||||
iconUrl: integration.iconUrl ?? getIconUrl(integration.kind),
|
||||
href: integration.url,
|
||||
pingUrl: integration.pingUrl ?? integration.url,
|
||||
description: integration.description ?? null,
|
||||
} satisfies typeof apps.$inferSelect,
|
||||
secretRows: integration.secrets.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
value: encryptSecret(secret.value),
|
||||
integrationId: id,
|
||||
})),
|
||||
};
|
||||
});
|
||||
const plannedAppRows = [
|
||||
...integrationPlans.map((plan) => plan.appRow),
|
||||
...input.apps.map(
|
||||
(app) =>
|
||||
({
|
||||
id: stableOnboardingId("app", app.sourceId),
|
||||
name: app.name,
|
||||
iconUrl: app.iconUrl ?? defaultAppIcon,
|
||||
href: app.href,
|
||||
pingUrl: app.pingUrl ?? app.href,
|
||||
description: app.description ?? null,
|
||||
}) satisfies typeof apps.$inferSelect,
|
||||
),
|
||||
];
|
||||
const plannedAppIds = plannedAppRows.map((app) => app.id);
|
||||
if (new Set(plannedAppIds).size !== plannedAppIds.length) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Onboarding app source IDs resolve to the same app." });
|
||||
}
|
||||
const existingPlannedApps =
|
||||
plannedAppIds.length > 0 ? await ctx.db.query.apps.findMany({ where: inArray(apps.id, plannedAppIds) }) : [];
|
||||
const existingPlannedAppIds = new Set(existingPlannedApps.map((app) => app.id));
|
||||
const appPlans = plannedAppRows.map((row) => ({ id: row.id, existing: existingPlannedAppIds.has(row.id), row }));
|
||||
|
||||
const plannedIntegrationIdSet = new Set(plannedIntegrationIds);
|
||||
const plannedAppIdSet = new Set(plannedAppIds);
|
||||
if (input.selectedIntegrationIds.some((id) => plannedIntegrationIdSet.has(id))) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "A new integration cannot also be selected by ID." });
|
||||
}
|
||||
if (input.selectedAppIds.some((id) => plannedAppIdSet.has(id))) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "A new app cannot also be selected by ID." });
|
||||
}
|
||||
const persistedSelectedIntegrations =
|
||||
input.selectedIntegrationIds.length > 0
|
||||
? await ctx.db.query.integrations.findMany({ where: inArray(integrations.id, input.selectedIntegrationIds) })
|
||||
: [];
|
||||
const selectedApps =
|
||||
const persistedSelectedApps =
|
||||
input.selectedAppIds.length > 0
|
||||
? await ctx.db.query.apps.findMany({ where: inArray(apps.id, input.selectedAppIds) })
|
||||
: [];
|
||||
if (selectedIntegrations.length !== input.selectedIntegrationIds.length) {
|
||||
if (persistedSelectedIntegrations.length !== input.selectedIntegrationIds.length) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "One or more selected integrations no longer exist." });
|
||||
}
|
||||
if (selectedApps.length !== input.selectedAppIds.length) {
|
||||
if (persistedSelectedApps.length !== input.selectedAppIds.length) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "One or more selected apps no longer exist." });
|
||||
}
|
||||
const selectedIntegrations = [...persistedSelectedIntegrations, ...integrationPlans.map((plan) => plan.row)];
|
||||
const selectedApps = [...persistedSelectedApps, ...plannedAppRows];
|
||||
|
||||
const selectedDockerSources =
|
||||
input.selectedDockerSourceIds.length > 0
|
||||
@@ -571,7 +517,11 @@ export const onboardRouter = createTRPCRouter({
|
||||
}
|
||||
targetBoardName = parsedName.data;
|
||||
}
|
||||
if (boardList.some((board) => board.id !== targetBoard.id && board.name === targetBoardName)) {
|
||||
if (
|
||||
boardList.some(
|
||||
(board) => board.id !== targetBoard.id && board.name.toLowerCase() === targetBoardName.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "A board with this name already exists." });
|
||||
}
|
||||
const targetBoardNames = new Set([targetBoard.name, targetBoardName]);
|
||||
@@ -770,8 +720,8 @@ export const onboardRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
for (const [kind, integrationIdsForWidget] of selectedIntegrationIdsByWidget) {
|
||||
const config = defaultWidgetConfigByKind.get(kind);
|
||||
if (config?.skip) continue;
|
||||
const config = getDefaultWidgetConfig(kind);
|
||||
if (config.skip) continue;
|
||||
const existing = existingItemsByKind.get(kind);
|
||||
const limit = widgetIntegrationLimits[kind] ?? Number.POSITIVE_INFINITY;
|
||||
let unlinkedIntegrationIds = integrationIdsForWidget;
|
||||
@@ -795,9 +745,9 @@ export const onboardRouter = createTRPCRouter({
|
||||
if (getWidgetIntegrationIssue(kind, integrationKindsForItem)) continue;
|
||||
const itemId = placeNewItem(
|
||||
kind,
|
||||
config?.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
|
||||
config?.width ?? 2,
|
||||
config?.height ?? 2,
|
||||
config.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
|
||||
config.width,
|
||||
config.height,
|
||||
);
|
||||
newIntegrationItems.push(...integrationIdsForItem.map((integrationId) => ({ itemId, integrationId })));
|
||||
}
|
||||
@@ -808,13 +758,13 @@ export const onboardRouter = createTRPCRouter({
|
||||
for (const kind of input.selectedWidgetKinds.filter((kind) => !selectedLabelWidgetKinds.has(kind))) {
|
||||
if (existingItemsByKind.has(kind) || newItems.some((item) => item.kind === kind)) continue;
|
||||
if (getWidgetIntegrationIssue(kind, [])) continue;
|
||||
const config = defaultWidgetConfigByKind.get(kind);
|
||||
if (config?.skip) continue;
|
||||
const config = getDefaultWidgetConfig(kind);
|
||||
if (config.skip) continue;
|
||||
placeNewItem(
|
||||
kind,
|
||||
config?.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
|
||||
config?.width ?? 2,
|
||||
config?.height ?? 2,
|
||||
config.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
|
||||
config.width,
|
||||
config.height,
|
||||
);
|
||||
}
|
||||
const dockerSourceByAppId = new Map(
|
||||
@@ -863,13 +813,13 @@ export const onboardRouter = createTRPCRouter({
|
||||
} else if (existing) {
|
||||
continue;
|
||||
}
|
||||
const config = defaultWidgetConfigByKind.get(service.widgetKind);
|
||||
if (config?.skip) continue;
|
||||
const config = getDefaultWidgetConfig(service.widgetKind);
|
||||
if (config.skip) continue;
|
||||
const itemId = placeNewItem(
|
||||
service.widgetKind,
|
||||
config?.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
|
||||
config?.width ?? 2,
|
||||
config?.height ?? 2,
|
||||
config.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
|
||||
config.width,
|
||||
config.height,
|
||||
);
|
||||
if (integration) newIntegrationItems.push({ itemId, integrationId: integration.id });
|
||||
}
|
||||
@@ -942,13 +892,13 @@ export const onboardRouter = createTRPCRouter({
|
||||
continue;
|
||||
}
|
||||
|
||||
const config = defaultWidgetConfigByKind.get(service.widgetKind);
|
||||
if (config?.skip) continue;
|
||||
const config = getDefaultWidgetConfig(service.widgetKind);
|
||||
if (config.skip) continue;
|
||||
groupedItemPlans.push({
|
||||
kind: service.widgetKind,
|
||||
options: config?.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
|
||||
width: config?.width ?? 2,
|
||||
height: config?.height ?? 2,
|
||||
options: config.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
|
||||
width: config.width,
|
||||
height: config.height,
|
||||
integrationId: integration?.id,
|
||||
});
|
||||
}
|
||||
@@ -1053,6 +1003,36 @@ export const onboardRouter = createTRPCRouter({
|
||||
await handleTransactionsAsync(ctx.db, {
|
||||
async handleAsync(db, schema) {
|
||||
await db.transaction(async (transaction) => {
|
||||
const transitionResult = (await transaction
|
||||
.update(schema.onboarding)
|
||||
.set({ previousStep: "setup", step: "finish" })
|
||||
.where(eq(schema.onboarding.step, "setup"))) as MySqlRawQueryResult | QueryResult;
|
||||
const transitionedRows = Array.isArray(transitionResult)
|
||||
? transitionResult[0].affectedRows
|
||||
: (transitionResult.rowCount ?? 0);
|
||||
if (transitionedRows !== 1) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "Onboarding setup was already completed." });
|
||||
}
|
||||
for (const plan of appPlans) {
|
||||
if (plan.existing) {
|
||||
await transaction.update(schema.apps).set(plan.row).where(eq(schema.apps.id, plan.id));
|
||||
} else {
|
||||
await transaction.insert(schema.apps).values(plan.row);
|
||||
}
|
||||
}
|
||||
for (const plan of integrationPlans) {
|
||||
if (plan.existing) {
|
||||
await transaction.update(schema.integrations).set(plan.row).where(eq(schema.integrations.id, plan.id));
|
||||
await transaction
|
||||
.delete(schema.integrationSecrets)
|
||||
.where(eq(schema.integrationSecrets.integrationId, plan.id));
|
||||
} else {
|
||||
await transaction.insert(schema.integrations).values(plan.row);
|
||||
}
|
||||
if (plan.secretRows.length > 0) {
|
||||
await transaction.insert(schema.integrationSecrets).values(plan.secretRows);
|
||||
}
|
||||
}
|
||||
const boardValues = {
|
||||
name: targetBoardName,
|
||||
primaryColor: input.board.primaryColor,
|
||||
@@ -1113,11 +1093,36 @@ export const onboardRouter = createTRPCRouter({
|
||||
await transaction
|
||||
.delete(schema.serverSettings)
|
||||
.where(eq(schema.serverSettings.settingKey, onboardingClaimSettingKey));
|
||||
await transaction.update(schema.onboarding).set({ previousStep: "setup", step: "finish" });
|
||||
});
|
||||
},
|
||||
handleSync(db) {
|
||||
db.transaction((transaction) => {
|
||||
const transitionResult = transaction
|
||||
.update(onboarding)
|
||||
.set({ previousStep: "setup", step: "finish" })
|
||||
.where(eq(onboarding.step, "setup"))
|
||||
.run();
|
||||
if (transitionResult.changes !== 1) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "Onboarding setup was already completed." });
|
||||
}
|
||||
for (const plan of appPlans) {
|
||||
if (plan.existing) {
|
||||
transaction.update(apps).set(plan.row).where(eq(apps.id, plan.id)).run();
|
||||
} else {
|
||||
transaction.insert(apps).values(plan.row).run();
|
||||
}
|
||||
}
|
||||
for (const plan of integrationPlans) {
|
||||
if (plan.existing) {
|
||||
transaction.update(integrations).set(plan.row).where(eq(integrations.id, plan.id)).run();
|
||||
transaction.delete(integrationSecrets).where(eq(integrationSecrets.integrationId, plan.id)).run();
|
||||
} else {
|
||||
transaction.insert(integrations).values(plan.row).run();
|
||||
}
|
||||
if (plan.secretRows.length > 0) {
|
||||
transaction.insert(integrationSecrets).values(plan.secretRows).run();
|
||||
}
|
||||
}
|
||||
const boardValues = {
|
||||
name: targetBoardName,
|
||||
primaryColor: input.board.primaryColor,
|
||||
@@ -1176,7 +1181,6 @@ export const onboardRouter = createTRPCRouter({
|
||||
.where(eq(groups.name, everyoneGroup))
|
||||
.run();
|
||||
transaction.delete(serverSettings).where(eq(serverSettings.settingKey, onboardingClaimSettingKey)).run();
|
||||
transaction.update(onboarding).set({ previousStep: "setup", step: "finish" }).run();
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -330,7 +330,11 @@ describe("getManageOverview", () => {
|
||||
const baseLayoutId = createId();
|
||||
const mobileLayoutId = createId();
|
||||
const rootSectionId = createId();
|
||||
const containerSectionId = createId();
|
||||
const itemId = createId();
|
||||
const mobileItemId = createId();
|
||||
const offscreenItemId = createId();
|
||||
const nestedItemId = createId();
|
||||
|
||||
await db.insert(users).values({ id: defaultCreatorId });
|
||||
await db.insert(boards).values([
|
||||
@@ -356,23 +360,75 @@ describe("getManageOverview", () => {
|
||||
role: "base",
|
||||
},
|
||||
]);
|
||||
await db.insert(sections).values({
|
||||
id: rootSectionId,
|
||||
boardId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
await db.insert(items).values({ id: itemId, boardId, kind: "clock" });
|
||||
await db.insert(itemLayouts).values({
|
||||
itemId,
|
||||
sectionId: rootSectionId,
|
||||
await db.insert(sections).values([
|
||||
{
|
||||
id: rootSectionId,
|
||||
boardId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
},
|
||||
{
|
||||
id: containerSectionId,
|
||||
boardId,
|
||||
kind: "container",
|
||||
xOffset: null,
|
||||
yOffset: null,
|
||||
},
|
||||
]);
|
||||
await db.insert(sectionLayouts).values({
|
||||
sectionId: containerSectionId,
|
||||
parentSectionId: rootSectionId,
|
||||
layoutId: baseLayoutId,
|
||||
xOffset: 2,
|
||||
yOffset: 1,
|
||||
width: 4,
|
||||
height: 2,
|
||||
xOffset: 0,
|
||||
yOffset: 4,
|
||||
width: 6,
|
||||
height: 3,
|
||||
});
|
||||
await db.insert(items).values([
|
||||
{ id: itemId, boardId, kind: "clock" },
|
||||
{ id: mobileItemId, boardId, kind: "clock" },
|
||||
{ id: offscreenItemId, boardId, kind: "clock" },
|
||||
{ id: nestedItemId, boardId, kind: "clock" },
|
||||
]);
|
||||
await db.insert(itemLayouts).values([
|
||||
{
|
||||
itemId,
|
||||
sectionId: rootSectionId,
|
||||
layoutId: baseLayoutId,
|
||||
xOffset: 2,
|
||||
yOffset: 1,
|
||||
width: 4,
|
||||
height: 2,
|
||||
},
|
||||
{
|
||||
itemId: mobileItemId,
|
||||
sectionId: rootSectionId,
|
||||
layoutId: mobileLayoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 3,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
itemId: offscreenItemId,
|
||||
sectionId: rootSectionId,
|
||||
layoutId: baseLayoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 12,
|
||||
width: 2,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
itemId: nestedItemId,
|
||||
sectionId: containerSectionId,
|
||||
layoutId: baseLayoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 2,
|
||||
height: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.getManageOverview();
|
||||
|
||||
@@ -388,9 +444,89 @@ describe("getManageOverview", () => {
|
||||
layouts: [{ layoutId: baseLayoutId, sectionId: rootSectionId, width: 4, height: 2 }],
|
||||
},
|
||||
],
|
||||
sections: [
|
||||
{ id: rootSectionId, kind: "empty" },
|
||||
{
|
||||
id: containerSectionId,
|
||||
kind: "container",
|
||||
layouts: [{ layoutId: baseLayoutId, parentSectionId: rootSectionId, yOffset: 4 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result[0]?.preview?.layouts).toHaveLength(1);
|
||||
expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([itemId]);
|
||||
expect(result[0]?.preview?.sections).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("uses the highest-breakpoint layout when a board has no base layout", async () => {
|
||||
const db = createDb();
|
||||
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
|
||||
const boardId = createId();
|
||||
const mobileLayoutId = createId();
|
||||
const tabletLayoutId = createId();
|
||||
const rootSectionId = createId();
|
||||
const mobileItemId = createId();
|
||||
const tabletItemId = createId();
|
||||
|
||||
await db.insert(users).values({ id: defaultCreatorId });
|
||||
await db.insert(boards).values({ id: boardId, name: "fallback-overview", creatorId: defaultCreatorId });
|
||||
await db.insert(layouts).values([
|
||||
{
|
||||
id: mobileLayoutId,
|
||||
name: "Mobile",
|
||||
boardId,
|
||||
columnCount: 3,
|
||||
breakpoint: 0,
|
||||
role: "mobile",
|
||||
},
|
||||
{
|
||||
id: tabletLayoutId,
|
||||
name: "Tablet",
|
||||
boardId,
|
||||
columnCount: 8,
|
||||
breakpoint: 600,
|
||||
role: "custom",
|
||||
},
|
||||
]);
|
||||
await db.insert(sections).values({
|
||||
id: rootSectionId,
|
||||
boardId,
|
||||
kind: "empty",
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
});
|
||||
await db.insert(items).values([
|
||||
{ id: mobileItemId, boardId, kind: "clock" },
|
||||
{ id: tabletItemId, boardId, kind: "clock" },
|
||||
]);
|
||||
await db.insert(itemLayouts).values([
|
||||
{
|
||||
itemId: mobileItemId,
|
||||
sectionId: rootSectionId,
|
||||
layoutId: mobileLayoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 3,
|
||||
height: 1,
|
||||
},
|
||||
{
|
||||
itemId: tabletItemId,
|
||||
sectionId: rootSectionId,
|
||||
layoutId: tabletLayoutId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 4,
|
||||
height: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await caller.getManageOverview();
|
||||
|
||||
expect(result[0]?.preview?.layouts).toEqual([
|
||||
expect.objectContaining({ id: tabletLayoutId, breakpoint: 600, role: "custom" }),
|
||||
]);
|
||||
expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([tabletItemId]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { groups, groupPermissions, onboarding } from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { groupRouter } from "../group";
|
||||
|
||||
const adminSession = {
|
||||
user: { id: "admin", permissions: ["admin"], colorScheme: "light" },
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
describe("group.createInitialExternalGroup", () => {
|
||||
it("commits exactly one concurrent group-to-setup transition", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "group", previousStep: "start" });
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
caller.createInitialExternalGroup({ name: "LDAP admins" }),
|
||||
caller.createInitialExternalGroup({ name: "OIDC admins" }),
|
||||
]);
|
||||
|
||||
expect(results.map((result) => result.status).toSorted()).toEqual(["fulfilled", "rejected"]);
|
||||
expect(results.find((result) => result.status === "rejected")).toMatchObject({
|
||||
reason: expect.objectContaining({ message: "The initial external group was already created." }),
|
||||
});
|
||||
const createdGroups = await db.select().from(groups);
|
||||
expect(createdGroups).toHaveLength(1);
|
||||
expect(["LDAP admins", "OIDC admins"]).toContain(createdGroups[0]?.name);
|
||||
expect(await db.select().from(groupPermissions)).toEqual([
|
||||
expect.objectContaining({ groupId: createdGroups[0]?.id, permission: "admin" }),
|
||||
]);
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "group" });
|
||||
});
|
||||
});
|
||||
@@ -20,12 +20,14 @@ import {
|
||||
} from "@homarr/db/schema";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { WidgetKind } from "@homarr/definitions";
|
||||
import type { OnboardingCompleteSetupInput } from "@homarr/validation/onboarding";
|
||||
|
||||
import { claimOnboardingAsync, isOnboardingClaimValidAsync } from "../../onboarding-claim";
|
||||
import * as integrationConnection from "../integration/integration-test-connection";
|
||||
|
||||
const listDiscoveredContainersAsync = vi.hoisted(() => vi.fn());
|
||||
const createIdMock = vi.hoisted(() => vi.fn());
|
||||
const isProviderEnabled = vi.hoisted(() => vi.fn<(provider: string) => boolean>(() => false));
|
||||
const stableAppIdForSource = (sourceId: string) => {
|
||||
const digest = createHash("sha256").update(sourceId).digest("hex").slice(0, 32);
|
||||
return `onboarding_app_${digest}`;
|
||||
@@ -35,7 +37,7 @@ const stableIntegrationIdForSource = (sourceId: string) => {
|
||||
return `onboarding_integration_${digest}`;
|
||||
};
|
||||
|
||||
vi.mock("@homarr/auth/server", () => ({ isProviderEnabled: () => false }));
|
||||
vi.mock("@homarr/auth/server", () => ({ isProviderEnabled }));
|
||||
vi.mock("@homarr/common", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@homarr/common")>()),
|
||||
createId: createIdMock,
|
||||
@@ -51,7 +53,7 @@ import { normalizeOnboardingStep } from "../onboard/onboard-queries";
|
||||
const createCaller = (db: ReturnType<typeof createDb>, onboardingClaimToken?: string) =>
|
||||
onboardRouter.createCaller({ db, deviceType: undefined, session: null, onboardingClaimToken });
|
||||
|
||||
const completionInput = (boardId: string, name = "Configured-board") => ({
|
||||
const completionInput = (boardId: string, name = "Configured-board"): OnboardingCompleteSetupInput => ({
|
||||
server: {
|
||||
defaultLocale: "en" as const,
|
||||
defaultColorScheme: "dark" as const,
|
||||
@@ -69,6 +71,8 @@ const completionInput = (boardId: string, name = "Configured-board") => ({
|
||||
},
|
||||
selectedIntegrationIds: [] as string[],
|
||||
selectedAppIds: [] as string[],
|
||||
integrations: [],
|
||||
apps: [],
|
||||
selectedDockerSourceIds: [] as string[],
|
||||
selectedWidgetKinds: [] as WidgetKind[],
|
||||
});
|
||||
@@ -188,6 +192,63 @@ describe("onboard.completeSetup", () => {
|
||||
expect(await isOnboardingClaimValidAsync(db, claimToken)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a stale completion after another submission claims the setup transition", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target" });
|
||||
const input = completionInput("target");
|
||||
input.selectedDockerSourceIds = ["docker:missing"];
|
||||
input.selectedWidgetKinds = ["clock"];
|
||||
|
||||
let releaseDiscovery: () => void = () => undefined;
|
||||
const discoveryGate = new Promise<void>((resolve) => {
|
||||
releaseDiscovery = resolve;
|
||||
});
|
||||
listDiscoveredContainersAsync.mockImplementation(async () => {
|
||||
await discoveryGate;
|
||||
return { hosts: [], services: [] };
|
||||
});
|
||||
|
||||
const submission = Promise.allSettled([createCaller(db, claimToken).completeSetup(input)]);
|
||||
try {
|
||||
await vi.waitFor(() => expect(listDiscoveredContainersAsync).toHaveBeenCalledOnce());
|
||||
await db.update(onboarding).set({ previousStep: "setup", step: "finish" });
|
||||
} finally {
|
||||
releaseDiscovery();
|
||||
}
|
||||
const [result] = await submission;
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "rejected",
|
||||
reason: expect.objectContaining({ message: "Onboarding setup was already completed." }),
|
||||
});
|
||||
expect(await db.query.items.findMany({ where: eq(items.kind, "clock") })).toHaveLength(0);
|
||||
expect(await db.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({ name: "target" });
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "finish", previousStep: "setup" });
|
||||
});
|
||||
|
||||
it("uses canonical widget sizes for automatically generated integration widgets", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target" });
|
||||
await db.insert(integrations).values({
|
||||
id: "sonarr",
|
||||
name: "Sonarr",
|
||||
url: "http://sonarr:8989",
|
||||
kind: "sonarr",
|
||||
});
|
||||
const input = completionInput("target");
|
||||
input.selectedIntegrationIds = ["sonarr"];
|
||||
|
||||
await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
const mediaMissing = await db.query.items.findFirst({ where: eq(items.kind, "mediaMissing") });
|
||||
expect(mediaMissing).toBeDefined();
|
||||
expect(
|
||||
await db.query.itemLayouts.findFirst({ where: eq(itemLayouts.itemId, mediaMissing?.id ?? "missing") }),
|
||||
).toMatchObject({ width: 4, height: 3 });
|
||||
});
|
||||
|
||||
it("requires an exact board id when more than one board exists", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
@@ -206,6 +267,21 @@ describe("onboard.completeSetup", () => {
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "group" });
|
||||
});
|
||||
|
||||
it("rejects a board rename that only differs by case from another board", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
await seedBoardAsync(db, { boardId: "target" });
|
||||
await seedBoardAsync(db, { boardId: "existing" });
|
||||
await db.update(boards).set({ name: "Media" }).where(eq(boards.id, "existing"));
|
||||
|
||||
await expect(createCaller(db, claimToken).completeSetup(completionInput("target", "media"))).rejects.toThrow(
|
||||
"A board with this name already exists.",
|
||||
);
|
||||
|
||||
expect(await db.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({ name: "target" });
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "group" });
|
||||
});
|
||||
|
||||
it("creates a complete first board when setup resumes without any boards", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
@@ -334,11 +410,12 @@ describe("onboard.completeSetup", () => {
|
||||
expect(await db.query.items.findMany()).toEqual([expect.objectContaining({ boardId: "target", kind: "clock" })]);
|
||||
});
|
||||
|
||||
it("persists discovery metadata on created apps and integration companion apps", async () => {
|
||||
it("creates selected apps, integrations, secrets, and companion apps in completeSetup", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
const caller = createCaller(db, claimToken);
|
||||
const appResult = await caller.createAppsFromDiscovery([
|
||||
await seedBoardAsync(db, { boardId: "target" });
|
||||
const input = completionInput("target");
|
||||
input.apps = [
|
||||
{
|
||||
sourceId: "docker:good:status",
|
||||
name: "Status",
|
||||
@@ -347,18 +424,9 @@ describe("onboard.completeSetup", () => {
|
||||
iconUrl: "https://icons.example/status.svg",
|
||||
description: "Internal status page",
|
||||
},
|
||||
]);
|
||||
const createdApp = appResult.apps[0];
|
||||
assert(createdApp);
|
||||
expect(await db.query.apps.findFirst({ where: eq(apps.id, createdApp.id) })).toMatchObject({
|
||||
pingUrl: "http://status:3001/health",
|
||||
iconUrl: "https://icons.example/status.svg",
|
||||
description: "Internal status page",
|
||||
});
|
||||
|
||||
const testConnection = vi.spyOn(integrationConnection, "testConnectionAsync").mockResolvedValue({ success: true });
|
||||
try {
|
||||
const integrationResult = await caller.createIntegration({
|
||||
];
|
||||
input.integrations = [
|
||||
{
|
||||
sourceId: "docker:good:sonarr",
|
||||
name: "Sonarr",
|
||||
url: "http://sonarr:8989",
|
||||
@@ -367,45 +435,85 @@ describe("onboard.completeSetup", () => {
|
||||
pingUrl: "http://sonarr:8989/ping",
|
||||
iconUrl: "https://icons.example/sonarr.svg",
|
||||
description: "TV automation",
|
||||
},
|
||||
];
|
||||
|
||||
const testConnection = vi.spyOn(integrationConnection, "testConnectionAsync").mockResolvedValue({ success: true });
|
||||
try {
|
||||
await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
expect(
|
||||
await db.query.apps.findFirst({ where: eq(apps.id, stableAppIdForSource("docker:good:status")) }),
|
||||
).toMatchObject({
|
||||
href: "http://status:3001",
|
||||
pingUrl: "http://status:3001/health",
|
||||
iconUrl: "https://icons.example/status.svg",
|
||||
description: "Internal status page",
|
||||
});
|
||||
const createdAppId = "appId" in integrationResult ? integrationResult.appId : undefined;
|
||||
expect(createdAppId).toBeDefined();
|
||||
if (!createdAppId) throw new Error("Expected integration creation to succeed");
|
||||
expect(await db.query.apps.findFirst({ where: eq(apps.id, createdAppId) })).toMatchObject({
|
||||
expect(
|
||||
await db.query.apps.findFirst({ where: eq(apps.id, stableAppIdForSource("docker:good:sonarr")) }),
|
||||
).toMatchObject({
|
||||
href: "http://sonarr:8989",
|
||||
pingUrl: "http://sonarr:8989/ping",
|
||||
iconUrl: "https://icons.example/sonarr.svg",
|
||||
description: "TV automation",
|
||||
});
|
||||
expect(
|
||||
await db.query.integrations.findFirst({
|
||||
where: eq(integrations.id, stableIntegrationIdForSource("docker:good:sonarr")),
|
||||
}),
|
||||
).toMatchObject({
|
||||
appId: stableAppIdForSource("docker:good:sonarr"),
|
||||
name: "Sonarr",
|
||||
url: "http://sonarr:8989",
|
||||
kind: "sonarr",
|
||||
});
|
||||
const savedSecrets = await db.query.integrationSecrets.findMany({
|
||||
where: eq(integrationSecrets.integrationId, stableIntegrationIdForSource("docker:good:sonarr")),
|
||||
});
|
||||
expect(savedSecrets).toEqual([expect.objectContaining({ kind: "apiKey" })]);
|
||||
expect(savedSecrets[0]?.value).not.toBe("secret");
|
||||
expect(await db.query.items.findMany({ where: eq(items.kind, "app") })).toHaveLength(2);
|
||||
} finally {
|
||||
testConnection.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("reuses a manual integration and companion app across retries", async () => {
|
||||
it("rolls draft records back with board writes and reuses stable IDs on retry", async () => {
|
||||
const db = createDb();
|
||||
const claimToken = await seedOnboardingAsync(db);
|
||||
const caller = createCaller(db, claimToken);
|
||||
await seedBoardAsync(db, { boardId: "target", itemId: "generated-id" });
|
||||
const sourceId = "manual:sonarr";
|
||||
const createInput = {
|
||||
sourceId,
|
||||
name: "Sonarr",
|
||||
url: "http://sonarr:8989",
|
||||
kind: "sonarr" as const,
|
||||
secrets: [{ kind: "apiKey" as const, value: "secret" }],
|
||||
};
|
||||
const input = completionInput("target", "Retried-board");
|
||||
input.integrations = [
|
||||
{
|
||||
sourceId,
|
||||
name: "Sonarr",
|
||||
url: "http://sonarr:8989",
|
||||
kind: "sonarr",
|
||||
secrets: [{ kind: "apiKey", value: "secret" }],
|
||||
},
|
||||
];
|
||||
createIdMock.mockReturnValue("generated-id");
|
||||
const testConnection = vi.spyOn(integrationConnection, "testConnectionAsync").mockResolvedValue({ success: true });
|
||||
|
||||
try {
|
||||
const first = await caller.createIntegration(createInput);
|
||||
const second = await caller.createIntegration(createInput);
|
||||
await expect(createCaller(db, claimToken).completeSetup(input)).rejects.toThrow();
|
||||
expect(await db.query.integrations.findMany()).toHaveLength(0);
|
||||
expect(await db.query.apps.findMany()).toHaveLength(0);
|
||||
expect(await db.query.integrationSecrets.findMany()).toHaveLength(0);
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "group" });
|
||||
|
||||
expect(first).toEqual(second);
|
||||
expect(first).toEqual({
|
||||
let nextRetryId = 0;
|
||||
createIdMock.mockImplementation(() => `retry-${++nextRetryId}`);
|
||||
await createCaller(db, claimToken).completeSetup(input);
|
||||
|
||||
expect(await db.query.integrations.findMany()).toHaveLength(1);
|
||||
expect(await db.query.apps.findMany()).toHaveLength(1);
|
||||
expect(await db.query.integrations.findFirst()).toMatchObject({
|
||||
id: stableIntegrationIdForSource(sourceId),
|
||||
appId: stableAppIdForSource(sourceId),
|
||||
});
|
||||
expect(await db.query.integrations.findMany()).toHaveLength(1);
|
||||
expect(await db.query.apps.findMany()).toHaveLength(1);
|
||||
expect(
|
||||
await db.query.integrationSecrets.findMany({
|
||||
where: eq(integrationSecrets.integrationId, stableIntegrationIdForSource(sourceId)),
|
||||
@@ -938,6 +1046,10 @@ describe("onboard.completeSetup", () => {
|
||||
});
|
||||
|
||||
describe("onboard.nextStep", () => {
|
||||
beforeEach(() => {
|
||||
isProviderEnabled.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("advances welcome exactly once", async () => {
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "start" });
|
||||
@@ -950,6 +1062,29 @@ describe("onboard.nextStep", () => {
|
||||
await expect(caller.nextStep()).rejects.toThrow("The welcome step is already complete.");
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "start" });
|
||||
});
|
||||
|
||||
it("skips duplicate credential account creation when an administrator resumes a legacy step", async () => {
|
||||
isProviderEnabled.mockImplementation((provider: string) => provider === "credentials");
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "import" as never });
|
||||
await db.insert(users).values({ id: "existing-admin", name: "admin", provider: "credentials" });
|
||||
|
||||
const { nextOnboardingStepAsync } = await import("../onboard/onboard-queries");
|
||||
await nextOnboardingStepAsync(db);
|
||||
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "start" });
|
||||
});
|
||||
|
||||
it("still requires credential account creation on a fresh installation", async () => {
|
||||
isProviderEnabled.mockImplementation((provider: string) => provider === "credentials");
|
||||
const db = createDb();
|
||||
await db.insert(onboarding).values({ id: "onboarding", step: "start" });
|
||||
|
||||
const { nextOnboardingStepAsync } = await import("../onboard/onboard-queries");
|
||||
await nextOnboardingStepAsync(db);
|
||||
|
||||
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "user", previousStep: "start" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeOnboardingStep", () => {
|
||||
|
||||
Reference in new issue
Block a user