diff --git a/apps/docs/blog/2025/01-19-migration-guide-1.0/index.mdx b/apps/docs/blog/2025/01-19-migration-guide-1.0/index.mdx
index fc4a49d47..48ce153fa 100644
--- a/apps/docs/blog/2025/01-19-migration-guide-1.0/index.mdx
+++ b/apps/docs/blog/2025/01-19-migration-guide-1.0/index.mdx
@@ -10,12 +10,11 @@ If you are still on a version before 0.15.10, please update to 0.15.10 where we
-## Export your data
+## Preserve your old installation
-As 1.0 is a complete rewrite of Homarr and therefor not compatible with the old docker mounts, we need to export your data first.
-For this you can navigate to the `Migrate to 1.0` page which you can find under `Management` > `Tools` > `Migrate to 1.0`.
-Here you can select which things you want to export and download the zip file.
-It will also show you a secret key which you will need to import the secret data like user password hashes, salts and integration secrets.
+Homarr 1.0 is a complete rewrite and cannot reuse the old Docker mounts. Keep the 0.15 installation and its data intact while you build the replacement. The historical `Management` > `Tools` > `Migrate to 1.0` export can still be kept as a record, but current Homarr releases cannot import that ZIP or its secret key.
+
+Plan to recreate your apps, integrations, and boards manually in the new installation. A SQLite database backup can be restored from the onboarding welcome screen only when it was created by a compatible current Homarr release; it is not a conversion path for a 0.15 database.

@@ -31,7 +30,7 @@ So in your compose file the ports would be `7576:7575` instead of `7575:7575`.
:::warning Legacy guide
-The pre-1.0 ZIP importer described by this historical migration guide is no longer included in current Homarr releases. Keep the old installation and export available while you recreate the required apps and boards in a supported release. SQLite database backups from current Homarr releases can still be restored from the onboarding welcome screen.
+The pre-1.0 ZIP importer is no longer included in current Homarr releases. Keep the old installation available until you have manually verified the replacement. Compatible SQLite backups from current releases can still be restored from the onboarding welcome screen.
:::
diff --git a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx
index e581d55a9..f37c730cd 100644
--- a/apps/nextjs/src/app/[locale]/manage/apps/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/apps/page.tsx
@@ -20,8 +20,18 @@ import { AppDeleteButton } from "./_app-delete-button";
const searchParamsSchema = z.object({
search: z.string().optional(),
- pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
- page: z.string().regex(/\d+/).transform(Number).catch(1),
+ pageSize: z
+ .string()
+ .regex(/^[1-9]\d*$/u)
+ .transform(Number)
+ .pipe(z.number().int().positive())
+ .catch(10),
+ page: z
+ .string()
+ .regex(/^[1-9]\d*$/u)
+ .transform(Number)
+ .pipe(z.number().int().positive())
+ .catch(1),
});
interface AppsPageProps {
@@ -87,7 +97,13 @@ export default async function AppsPage(props: AppsPageProps) {
floatingPrimaryAction={canCreate}
>
{apps.map((app) => (
-
+
))}
);
@@ -99,10 +115,10 @@ interface AppItemProps {
app: RouterOutputs["app"]["getPaginated"]["items"][number];
canModify: boolean;
canDelete: boolean;
+ editLabel: string;
}
-const AppItem = async ({ app, canModify, canDelete }: AppItemProps) => {
- const t = await getScopedI18n("app");
+const AppItem = ({ app, canModify, canDelete, editLabel }: AppItemProps) => {
const descriptionLines = app.description?.split("\n");
const safeHref = getSafeAppHref(app.href);
@@ -150,7 +166,7 @@ const AppItem = async ({ app, canModify, canDelete }: AppItemProps) => {
variant="subtle"
color="gray"
size={44}
- aria-label={t("page.list.action.edit", { name: app.name })}
+ aria-label={editLabel}
>
diff --git a/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx b/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx
index e5ae194af..744f998d5 100644
--- a/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/boards/_components/board-card-menu-dropdown.tsx
@@ -22,7 +22,7 @@ const iconProps = {
interface BoardCardMenuDropdownProps {
board: Pick<
- RouterOutputs["board"]["getAllBoards"][number],
+ RouterOutputs["board"]["getManageOverview"][number],
"id" | "name" | "creator" | "userPermissions" | "groupPermissions" | "isPublic"
>;
}
diff --git a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx
index 7db861c6d..fedcefa3b 100644
--- a/apps/nextjs/src/app/[locale]/manage/boards/page.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/boards/page.tsx
@@ -70,6 +70,7 @@ export default async function ManageBoardsPage() {
name: board.name,
count: String(board.preview?.items.length ?? 0),
}),
+ unknownCreator: t("preview.unknownCreator"),
home: t("action.setHomeBoard.badge.label"),
homeTooltip: t("action.setHomeBoard.badge.tooltip"),
mobileHome: t("action.setMobileHomeBoard.badge.label"),
@@ -93,6 +94,7 @@ interface BoardCardProps {
labels: {
visibility: string;
preview: string;
+ unknownCreator: string;
home: string;
homeTooltip: string;
mobileHome: string;
@@ -139,7 +141,7 @@ const BoardCard = ({ board, isMenuVisible, labels }: BoardCardProps) => {
)}
{board.creator && (
-
+
)}
diff --git a/apps/nextjs/src/app/api/onboarding/claim/route.ts b/apps/nextjs/src/app/api/onboarding/claim/route.ts
index df547d39a..8f1cca888 100644
--- a/apps/nextjs/src/app/api/onboarding/claim/route.ts
+++ b/apps/nextjs/src/app/api/onboarding/claim/route.ts
@@ -13,7 +13,7 @@ const noStoreHeaders = { "Cache-Control": "no-store" };
export async function POST(request: Request) {
const fetchSite = request.headers.get("sec-fetch-site");
if (fetchSite && fetchSite !== "same-origin" && fetchSite !== "none") {
- return NextResponse.json({ error: "Cross-site onboarding claims are not allowed." }, { status: 403 });
+ return NextResponse.json({ code: "cross_site" }, { status: 403 });
}
const session = await auth();
@@ -22,21 +22,18 @@ export async function POST(request: Request) {
force: session?.user.permissions.includes("admin") ?? false,
});
if (result.status === "finished") {
- const response = NextResponse.json({ error: "Onboarding is already complete." }, { status: 409 });
+ const response = NextResponse.json({ code: "finished" }, { status: 409 });
response.cookies.delete(onboardingClaimCookieName);
return response;
}
if (result.status === "forbidden") {
- return NextResponse.json({ error: "Administrator access is required." }, { status: 403 });
+ return NextResponse.json({ code: "administrator_required" }, { status: 403 });
}
if (result.status === "locked") {
- return NextResponse.json(
- { error: "Another browser has already claimed onboarding.", expiresAt: result.expiresAt },
- { status: 423, headers: noStoreHeaders },
- );
+ return NextResponse.json({ code: "locked", expiresAt: result.expiresAt }, { status: 423, headers: noStoreHeaders });
}
if (result.status !== "issued" && result.status !== "active") {
- return NextResponse.json({ error: "Onboarding could not be claimed." }, { status: 409 });
+ return NextResponse.json({ code: "unavailable" }, { status: 409 });
}
const forwardedProtocol = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
diff --git a/apps/nextjs/src/components/board/board-layout-thumbnail.tsx b/apps/nextjs/src/components/board/board-layout-thumbnail.tsx
index e8cdb7a0a..829df79b8 100644
--- a/apps/nextjs/src/components/board/board-layout-thumbnail.tsx
+++ b/apps/nextjs/src/components/board/board-layout-thumbnail.tsx
@@ -15,7 +15,7 @@ interface BoardLayoutThumbnailProps {
const maxThumbnailRows = 12;
export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailProps) => {
- const layout = preview?.layouts.at(0);
+ const layout = preview?.layouts.find((candidate) => candidate.role === "base") ?? preview?.layouts.at(0);
if (!preview || !layout) {
return (
@@ -25,7 +25,9 @@ export const BoardLayoutThumbnail = ({ preview, label }: BoardLayoutThumbnailPro
}
const elements = projectBoardLayout(preview, layout, layout);
- const roots = preview.sections.filter((section) => section.kind === "empty");
+ const roots = preview.sections
+ .filter((section) => section.kind === "empty")
+ .toSorted((first, second) => (first.xOffset ?? 0) - (second.xOffset ?? 0) || first.id.localeCompare(second.id));
const lanes = boardLanes.flatMap((lane) => {
const root = roots.find((section) => getRootSectionLane(section.xOffset) === lane);
const columnCount = getBoardLaneColumnCount(layout, lane);
diff --git a/apps/nextjs/src/components/manage/manage-collection.tsx b/apps/nextjs/src/components/manage/manage-collection.tsx
index 16ce87131..ef8923f8a 100644
--- a/apps/nextjs/src/components/manage/manage-collection.tsx
+++ b/apps/nextjs/src/components/manage/manage-collection.tsx
@@ -52,7 +52,7 @@ export const ManageCollectionItem = ({
{title}
- {badges}
+ {badges && {badges} }
{description}
{metadata}
diff --git a/apps/nextjs/src/proxy.spec.ts b/apps/nextjs/src/proxy.spec.ts
index f1ce7681c..a13d3185e 100644
--- a/apps/nextjs/src/proxy.spec.ts
+++ b/apps/nextjs/src/proxy.spec.ts
@@ -72,6 +72,17 @@ describe("locale and onboarding proxy", () => {
expect(mocks.getOnboardingStep).not.toHaveBeenCalled();
});
+ it("does not treat nested routes ending in init or auth/login as onboarding access routes", async () => {
+ mocks.getOnboardingStep.mockResolvedValue("setup");
+ const { proxy } = await loadProxy();
+
+ const initResponse = await proxy(createRequest("/en/boards/init"));
+ const loginResponse = await proxy(createRequest("/en/settings/auth/login"));
+
+ expect(initResponse.headers.get("location")).toBe("http://localhost/init");
+ expect(loginResponse.headers.get("location")).toBe("http://localhost/init");
+ });
+
it("uses the configured default locale for a missing or invalid locale cookie", async () => {
mocks.getDefaultLocale.mockResolvedValue("de");
const { proxy } = await loadProxy();
diff --git a/apps/nextjs/src/proxy.ts b/apps/nextjs/src/proxy.ts
index d89c785ee..58ffde679 100644
--- a/apps/nextjs/src/proxy.ts
+++ b/apps/nextjs/src/proxy.ts
@@ -42,7 +42,11 @@ const getDefaultLocaleDedupedAsync = () => {
export async function proxy(request: NextRequest) {
// Redirect to onboarding if it's not finished yet
const pathname = request.nextUrl.pathname;
- const isOnboardingAccessRoute = /\/(?:init|auth\/login)\/?$/.test(pathname);
+ const segments = pathname.split("/").filter(Boolean);
+ const routeSegments = supportedLanguages.includes(segments[0] as SupportedLanguage) ? segments.slice(1) : segments;
+ const isOnboardingAccessRoute =
+ (routeSegments.length === 1 && routeSegments[0] === "init") ||
+ (routeSegments.length === 2 && routeSegments[0] === "auth" && routeSegments[1] === "login");
if (!isOnboardingFinished && !isOnboardingAccessRoute) {
const currentOnboardingStep = await getOnboardingStepDedupedAsync();
diff --git a/e2e/onboarding.spec.ts b/e2e/onboarding.spec.ts
index f74c60d07..1ba87df77 100644
--- a/e2e/onboarding.spec.ts
+++ b/e2e/onboarding.spec.ts
@@ -1,4 +1,4 @@
-import { mkdir, readFile } from "node:fs/promises";
+import { mkdir, readFile, rm } from "node:fs/promises";
import path from "node:path";
import { chromium } from "@playwright/test";
@@ -23,41 +23,53 @@ const captureScreenshotAsync = async (page: Page, name: string) => {
await page.screenshot({ path: path.join(directory, `onboarding-${name}.png`), fullPage: true });
};
-const createRestoreArchiveAsync = async () => {
- const { db, localMountPath } = await createSqliteDbFileAsync();
- await db.insert(sqliteSchema.onboarding).values({ id: "onboarding", step: "finish", previousStep: "setup" });
- await db.insert(sqliteSchema.boards).values({ id: "restored-board", name: "restored", isPublic: true });
- await db.insert(sqliteSchema.layouts).values({
- id: "restored-base",
- boardId: "restored-board",
- name: "Base",
- role: "base",
- columnCount: 10,
- breakpoint: 768,
- });
- await db.insert(sqliteSchema.sections).values({
- id: "restored-root",
- boardId: "restored-board",
- kind: "empty",
- xOffset: 0,
- yOffset: 0,
- });
- db.$client.close();
+type SqliteTestDatabase = Awaited>;
- const zip = new AdmZip();
- zip.addFile("db.sqlite", await readFile(path.join(localMountPath, "db", "db.sqlite")));
- zip.addFile(
- "metadata.json",
- Buffer.from(
- JSON.stringify({
- homarrVersion: "e2e",
- exportedAt: new Date().toISOString(),
- dbDialect: "sqlite",
- encryptionKey: "0".repeat(64),
- }),
- ),
- );
- return zip.toBuffer();
+const cleanupSqliteDbAsync = async ({ db, localMountPath }: SqliteTestDatabase) => {
+ if (db.$client.open) db.$client.close();
+ await rm(localMountPath, { recursive: true, force: true });
+};
+
+const createRestoreArchiveAsync = async () => {
+ const sqlite = await createSqliteDbFileAsync();
+ const { db, localMountPath } = sqlite;
+ try {
+ await db.insert(sqliteSchema.onboarding).values({ id: "onboarding", step: "finish", previousStep: "setup" });
+ await db.insert(sqliteSchema.boards).values({ id: "restored-board", name: "restored", isPublic: true });
+ await db.insert(sqliteSchema.layouts).values({
+ id: "restored-base",
+ boardId: "restored-board",
+ name: "Base",
+ role: "base",
+ columnCount: 10,
+ breakpoint: 768,
+ });
+ await db.insert(sqliteSchema.sections).values({
+ id: "restored-root",
+ boardId: "restored-board",
+ kind: "empty",
+ xOffset: 0,
+ yOffset: 0,
+ });
+ db.$client.close();
+
+ const zip = new AdmZip();
+ zip.addFile("db.sqlite", await readFile(path.join(localMountPath, "db", "db.sqlite")));
+ zip.addFile(
+ "metadata.json",
+ Buffer.from(
+ JSON.stringify({
+ homarrVersion: "e2e",
+ exportedAt: new Date().toISOString(),
+ dbDialect: "sqlite",
+ encryptionKey: "0".repeat(64),
+ }),
+ ),
+ );
+ return zip.toBuffer();
+ } finally {
+ await cleanupSqliteDbAsync(sqlite);
+ }
};
describe("Onboarding", () => {
@@ -68,7 +80,8 @@ describe("Onboarding", () => {
])(
"Onboarding is usable at the $name viewport",
async ({ name, width, height }) => {
- const { localMountPath } = await createSqliteDbFileAsync();
+ const sqlite = await createSqliteDbFileAsync();
+ const { localMountPath } = sqlite;
const homarrContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
const browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width, height }, reducedMotion: "reduce" });
@@ -159,7 +172,11 @@ describe("Onboarding", () => {
expect(await boardCanvas.locator("[data-grid-item-id]").count()).toBeGreaterThanOrEqual(3);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true);
} finally {
- await Promise.all([browser.close(), homarrContainer.stop()]);
+ try {
+ await Promise.all([browser.close(), homarrContainer.stop()]);
+ } finally {
+ await cleanupSqliteDbAsync(sqlite);
+ }
}
},
90_000,
@@ -169,7 +186,8 @@ describe("Onboarding", () => {
const restoreArchive = await createRestoreArchiveAsync();
const invalidArchive = new AdmZip();
invalidArchive.addFile("db.sqlite", Buffer.from("not-a-database"));
- const { localMountPath } = await createSqliteDbFileAsync();
+ const sqlite = await createSqliteDbFileAsync();
+ const { localMountPath } = sqlite;
const initialContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
let initialContainerStopped = false;
let restoredContainer: StartedTestContainer | undefined;
@@ -209,15 +227,20 @@ describe("Onboarding", () => {
await page.locator('[data-testid="board-canvas"][data-board-hydrated="true"]').waitFor({ timeout: 30_000 });
expect(page.url()).toMatch(/\/boards\/restored(?:[/?#]|$)/);
} finally {
- await browser.close();
- if (!initialContainerStopped) await initialContainer.stop();
- if (restoredContainer) await restoredContainer.stop();
+ try {
+ await browser.close();
+ if (!initialContainerStopped) await initialContainer.stop();
+ if (restoredContainer) await restoredContainer.stop();
+ } finally {
+ await cleanupSqliteDbAsync(sqlite);
+ }
}
}, 120_000);
test("Credentials onboarding should be successful", async () => {
// Arrange
- const { db, localMountPath } = await createSqliteDbFileAsync();
+ const sqlite = await createSqliteDbFileAsync();
+ const { db, localMountPath } = sqlite;
const homarrContainer = await createHomarrContainer({
mounts: {
"/appdata": localMountPath,
@@ -255,12 +278,17 @@ describe("Onboarding", () => {
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
await assertions.assertDbOnboardingStepAsync("finish");
} finally {
- await Promise.all([browser.close(), homarrContainer.stop()]);
+ try {
+ await Promise.all([browser.close(), homarrContainer.stop()]);
+ } finally {
+ await cleanupSqliteDbAsync(sqlite);
+ }
}
}, 60_000);
test("Credentials onboarding recovers when automatic sign-in fails after account creation", async () => {
- const { db, localMountPath } = await createSqliteDbFileAsync();
+ const sqlite = await createSqliteDbFileAsync();
+ const { db, localMountPath } = sqlite;
const homarrContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start();
const browser = await chromium.launch();
const context = await browser.newContext();
@@ -286,13 +314,18 @@ describe("Onboarding", () => {
expect((await db.query.users.findMany()).length).toBe(1);
expect((await db.query.onboarding.findFirst())?.step).toBe("setup");
} finally {
- await Promise.all([browser.close(), homarrContainer.stop()]);
+ try {
+ await Promise.all([browser.close(), homarrContainer.stop()]);
+ } finally {
+ await cleanupSqliteDbAsync(sqlite);
+ }
}
}, 60_000);
test("External provider onboarding requires sign-in before privileged setup", async () => {
// Arrange
- const { db, localMountPath } = await createSqliteDbFileAsync();
+ const sqlite = await createSqliteDbFileAsync();
+ const { db, localMountPath } = sqlite;
const homarrContainer = await createHomarrContainer({
environment: {
AUTH_PROVIDERS: "ldap",
@@ -329,13 +362,18 @@ describe("Onboarding", () => {
await assertions.assertExternalGroupInsertedAsync(externalGroupName);
await assertions.assertDbOnboardingStepAsync("setup");
} finally {
- await Promise.all([browser.close(), homarrContainer.stop()]);
+ try {
+ await Promise.all([browser.close(), homarrContainer.stop()]);
+ } finally {
+ await cleanupSqliteDbAsync(sqlite);
+ }
}
}, 60_000);
test("Legacy setup requires sign-in without exposing private board metadata", async () => {
const credentials = { username: "admin", password: "Comp(exP4sswOrd" };
- const { db, localMountPath } = await createSqliteDbFileAsync();
+ const sqlite = await createSqliteDbFileAsync();
+ const { db, localMountPath } = sqlite;
await seedAdminUserAsync(db, credentials);
await db.update(sqliteSchema.onboarding).set({ step: "import" as never });
const privateBoardName = "private-board-must-not-leak";
@@ -376,13 +414,18 @@ describe("Onboarding", () => {
await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor();
expect(await page.getByRole("heading", { name: "Create your administrator" }).count()).toBe(0);
} finally {
- await Promise.all([browser.close(), homarrContainer.stop()]);
+ try {
+ await Promise.all([browser.close(), homarrContainer.stop()]);
+ } finally {
+ await cleanupSqliteDbAsync(sqlite);
+ }
}
}, 90_000);
test("Assistant setup restores saved configuration and accepts a manual model", async () => {
const credentials = { username: "admin", password: "Comp(exP4sswOrd" };
- const { db, localMountPath } = await createSqliteDbFileAsync();
+ const sqlite = await createSqliteDbFileAsync();
+ const { db, localMountPath } = sqlite;
await seedAdminUserAsync(db, credentials);
await db.update(sqliteSchema.onboarding).set({ step: "setup", previousStep: "group" });
await db.insert(sqliteSchema.assistantConfigurations).values({
@@ -419,7 +462,11 @@ describe("Onboarding", () => {
expect(await page.getByLabel("Default model").inputValue()).toBe("local-model");
expect(await page.getByText("Model discovery is unavailable.").count()).toBe(1);
} finally {
- await Promise.all([browser.close(), homarrContainer.stop()]);
+ try {
+ await Promise.all([browser.close(), homarrContainer.stop()]);
+ } finally {
+ await cleanupSqliteDbAsync(sqlite);
+ }
}
}, 90_000);
});
diff --git a/packages/api/src/onboarding-claim.ts b/packages/api/src/onboarding-claim.ts
index 7298227d4..87a2a9bf9 100644
--- a/packages/api/src/onboarding-claim.ts
+++ b/packages/api/src/onboarding-claim.ts
@@ -49,7 +49,12 @@ export const getOnboardingClaimTokenFromCookieHeader = (cookieHeader: string | n
const name = part.slice(0, separator).trim();
if (name !== onboardingClaimCookieName) continue;
const value = part.slice(separator + 1).trim();
- return value || undefined;
+ if (!value) return undefined;
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return undefined;
+ }
}
return undefined;
};
diff --git a/packages/api/src/router/app.ts b/packages/api/src/router/app.ts
index c25e1edad..3698d7f86 100644
--- a/packages/api/src/router/app.ts
+++ b/packages/api/src/router/app.ts
@@ -19,8 +19,6 @@ const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@mas
export const appRouter = createTRPCRouter({
getPaginated: protectedProcedure
- .input(paginatedSchema)
- .output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
.meta({
openapi: {
method: "GET",
@@ -34,6 +32,8 @@ export const appRouter = createTRPCRouter({
"List apps with pagination. OPTIONAL: search (string to filter by name), pageSize (number, default 10), page (number, default 1). All fields are optional — call with no arguments to get the first page",
},
})
+ .input(paginatedSchema)
+ .output(z.object({ items: z.array(selectAppSchema), totalCount: z.number() }))
.query(async ({ input, ctx }) => {
const whereQuery = input.search ? like(apps.name, `%${input.search.trim()}%`) : undefined;
const totalCount = await ctx.db.$count(apps, whereQuery);
diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts
index e75d88178..440b4eb45 100644
--- a/packages/api/src/router/board.ts
+++ b/packages/api/src/router/board.ts
@@ -242,7 +242,6 @@ export const boardRouter = createTRPCRouter({
id: true,
name: true,
image: true,
- email: true,
},
},
userPermissions: {
diff --git a/packages/api/src/router/onboard/onboard-queries.ts b/packages/api/src/router/onboard/onboard-queries.ts
index 969ffb528..66472b4ce 100644
--- a/packages/api/src/router/onboard/onboard-queries.ts
+++ b/packages/api/src/router/onboard/onboard-queries.ts
@@ -7,7 +7,6 @@ export const nextOnboardingStepAsync = async (db: Database) => {
const { current } = await getOnboardingOrFallbackAsync(db);
if (current !== "start") return;
const nextStep = await getNextOnboardingStepAsync(db);
- if (!nextStep) return;
await db.update(onboarding).set({ previousStep: current, step: nextStep });
};
diff --git a/packages/api/src/router/onboard/onboard-router.ts b/packages/api/src/router/onboard/onboard-router.ts
index a470b9081..f149a2a40 100644
--- a/packages/api/src/router/onboard/onboard-router.ts
+++ b/packages/api/src/router/onboard/onboard-router.ts
@@ -8,6 +8,8 @@ import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { encryptSecret } from "@homarr/common/server";
+import { createLogger } from "@homarr/core/infrastructure/logs";
+import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { handleTransactionsAsync, eq, inArray, like, or } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import {
@@ -50,6 +52,8 @@ import { MissingSecretError, testConnectionAsync } from "../integration/integrat
import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries";
import { probeRuntimeCapabilitiesAsync } from "./capability-probes";
+const logger = createLogger({ module: "onboardRouter" });
+
interface PortInfo {
IP?: string;
PublicPort?: number;
@@ -159,7 +163,7 @@ export const onboardRouter = createTRPCRouter({
const empty = { integrations: [] as DiscoveredIntegration[], apps: [] as DiscoveredApp[] };
try {
- const { listDiscoveredContainersAsync, dockerLabels } = await import("@homarr/docker");
+ const { createDockerSourceId, listDiscoveredContainersAsync, dockerLabels } = await import("@homarr/docker");
const discovery = await listDiscoveredContainersAsync();
const successfulHosts = discovery.hosts.filter((host) => host.status === "success");
const unavailableHosts = discovery.hosts.filter((host) => host.status === "unavailable");
@@ -169,14 +173,15 @@ export const onboardRouter = createTRPCRouter({
.map((container) => ({ ...container, host: hostResult.host })),
);
const labeledContainerKeys = new Set(
- discovery.services.map((service) => `${service.host}:${service.containerId}`),
+ discovery.services.map((service) => createDockerSourceId(service.host, service.containerId)),
);
const imageCandidates = containers.filter(
- (container) => !labeledContainerKeys.has(`${container.host}:${container.Id}`),
- );
- const likeQueries = imageCandidates.map((container) =>
- like(icons.name, `%${extractContainerImageName(container.Image)}%`),
+ (container) => !labeledContainerKeys.has(createDockerSourceId(container.host, container.Id)),
);
+ const candidateImageNames = [
+ ...new Set(imageCandidates.map((container) => extractContainerImageName(container.Image)).filter(Boolean)),
+ ].slice(0, 64);
+ const likeQueries = candidateImageNames.map((imageName) => like(icons.name, `%${imageName}%`));
const dbIcons = likeQueries.length > 0 ? await ctx.db.query.icons.findMany({ where: or(...likeQueries) }) : [];
const cdnIconUrl = (slug: string) =>
`https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/${slug}.svg`;
@@ -237,7 +242,7 @@ export const onboardRouter = createTRPCRouter({
const iconUrl = dbIcon ?? cdnIconUrl(imageName.toLowerCase());
const { url: suggestedUrl, publishedPort } = buildSuggestedUrl(container.Ports, container.host);
const kind = matchIntegrationKindFromContainer({ image: container.Image, name: containerName });
- const sourceId = `docker:${container.host}:${container.Id}`;
+ const sourceId = createDockerSourceId(container.host, container.Id);
if (kind) {
discoveredIntegrations.push({
@@ -281,6 +286,7 @@ export const onboardRouter = createTRPCRouter({
return { status, hosts, integrations: discoveredIntegrations, apps: discoveredApps };
} catch (error) {
+ logger.warn(new ErrorWithMetadata("Docker discovery failed during onboarding", {}, { cause: error }));
const reason = error instanceof Error ? error.message : "Docker discovery failed";
return {
status: "unavailable" as const,
@@ -306,6 +312,9 @@ export const onboardRouter = createTRPCRouter({
: boardList.length === 1
? boardList[0]
: undefined;
+ if (input.board.id !== undefined && !existingTargetBoard) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "The selected board no longer exists." });
+ }
const pendingBoardRow =
boardList.length === 0 && input.board.id === undefined
? {
@@ -701,6 +710,48 @@ export const onboardRouter = createTRPCRouter({
return id;
};
+ const getDockerWidgetPlan = (service: (typeof selectedDockerSources)[number]) => {
+ const widgetKind = service.widgetKind;
+ if (!widgetKind || !input.selectedWidgetKinds.includes(widgetKind)) return null;
+ const integration = service.integrationKind
+ ? selectedIntegrations.find(
+ (candidate) => candidate.id === stableOnboardingId("integration", service.sourceId),
+ )
+ : undefined;
+ const issue = getWidgetIntegrationIssue(widgetKind, integration ? [integration.kind] : []);
+ if (issue) {
+ if (issue.code !== "integration-limit") {
+ skippedWidgets.push({
+ sourceId: service.sourceId,
+ widgetKind,
+ code: issue.code,
+ integrationKind: integration?.kind,
+ });
+ }
+ return null;
+ }
+ const config = getDefaultWidgetConfig(widgetKind);
+ return config.skip ? null : { widgetKind, integration, config };
+ };
+
+ const linkExistingDockerWidget = (
+ widgetKind: WidgetKind,
+ integration: (typeof selectedIntegrations)[number] | undefined,
+ ) => {
+ const existing = existingItemsByKind.get(widgetKind);
+ if (!existing) return false;
+ if (!integration) return true;
+ const linkedIds = new Set([
+ ...existing.integrations.map((row) => row.integrationId),
+ ...newIntegrationItems.filter((row) => row.itemId === existing.id).map((row) => row.integrationId),
+ ]);
+ if (linkedIds.has(integration.id)) return true;
+ const limit = widgetIntegrationLimits[widgetKind] ?? Number.POSITIVE_INFINITY;
+ if (linkedIds.size >= limit) return false;
+ newIntegrationItems.push({ itemId: existing.id, integrationId: integration.id });
+ return true;
+ };
+
const selectedIntegrationIdsByWidget = new Map();
const labeledIntegrationSourceById = new Map(
selectedDockerSources
@@ -780,48 +831,15 @@ export const onboardRouter = createTRPCRouter({
if (app && !existingAppIds.has(app.id)) {
placeNewItem("app", SuperJSON.stringify({ appId: app.id, openInNewTab: true, showTitle: true }), 1, 1);
}
- if (!service.widgetKind || !input.selectedWidgetKinds.includes(service.widgetKind)) continue;
- const integration = service.integrationKind
- ? selectedIntegrations.find(
- (candidate) => candidate.id === stableOnboardingId("integration", service.sourceId),
- )
- : undefined;
- const issue = getWidgetIntegrationIssue(service.widgetKind, integration ? [integration.kind] : []);
- if (issue) {
- if (issue.code !== "integration-limit") {
- skippedWidgets.push({
- sourceId: service.sourceId,
- widgetKind: service.widgetKind,
- code: issue.code,
- integrationKind: integration?.kind,
- });
- }
- continue;
- }
- const existing = existingItemsByKind.get(service.widgetKind);
- if (existing && integration) {
- const linkedIds = new Set([
- ...existing.integrations.map((row) => row.integrationId),
- ...newIntegrationItems.filter((row) => row.itemId === existing.id).map((row) => row.integrationId),
- ]);
- const limit = widgetIntegrationLimits[service.widgetKind] ?? Number.POSITIVE_INFINITY;
- if (!linkedIds.has(integration.id) && linkedIds.size < limit) {
- newIntegrationItems.push({ itemId: existing.id, integrationId: integration.id });
- continue;
- }
- if (linkedIds.has(integration.id)) continue;
- } else if (existing) {
- continue;
- }
- const config = getDefaultWidgetConfig(service.widgetKind);
- if (config.skip) continue;
+ const plan = getDockerWidgetPlan(service);
+ if (!plan || linkExistingDockerWidget(plan.widgetKind, plan.integration)) continue;
const itemId = placeNewItem(
- service.widgetKind,
- config.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
- config.width,
- config.height,
+ plan.widgetKind,
+ plan.config.options ? SuperJSON.stringify(plan.config.options) : emptySuperJSON,
+ plan.config.width,
+ plan.config.height,
);
- if (integration) newIntegrationItems.push({ itemId, integrationId: integration.id });
+ if (plan.integration) newIntegrationItems.push({ itemId, integrationId: plan.integration.id });
}
const groupedSourcesByName = Map.groupBy(
@@ -857,49 +875,14 @@ export const onboardRouter = createTRPCRouter({
});
}
- if (!service.widgetKind || !input.selectedWidgetKinds.includes(service.widgetKind)) continue;
- const integration = service.integrationKind
- ? selectedIntegrations.find(
- (candidate) => candidate.id === stableOnboardingId("integration", service.sourceId),
- )
- : undefined;
- const issue = getWidgetIntegrationIssue(service.widgetKind, integration ? [integration.kind] : []);
- if (issue) {
- if (issue.code !== "integration-limit") {
- skippedWidgets.push({
- sourceId: service.sourceId,
- widgetKind: service.widgetKind,
- code: issue.code,
- integrationKind: integration?.kind,
- });
- }
- continue;
- }
-
- const existing = existingItemsByKind.get(service.widgetKind);
- if (existing && integration) {
- const linkedIds = new Set([
- ...existing.integrations.map((row) => row.integrationId),
- ...newIntegrationItems.filter((row) => row.itemId === existing.id).map((row) => row.integrationId),
- ]);
- const limit = widgetIntegrationLimits[service.widgetKind] ?? Number.POSITIVE_INFINITY;
- if (!linkedIds.has(integration.id) && linkedIds.size < limit) {
- newIntegrationItems.push({ itemId: existing.id, integrationId: integration.id });
- continue;
- }
- if (linkedIds.has(integration.id)) continue;
- } else if (existing) {
- continue;
- }
-
- const config = getDefaultWidgetConfig(service.widgetKind);
- if (config.skip) continue;
+ const plan = getDockerWidgetPlan(service);
+ if (!plan || linkExistingDockerWidget(plan.widgetKind, plan.integration)) continue;
groupedItemPlans.push({
- kind: service.widgetKind,
- options: config.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
- width: config.width,
- height: config.height,
- integrationId: integration?.id,
+ kind: plan.widgetKind,
+ options: plan.config.options ? SuperJSON.stringify(plan.config.options) : emptySuperJSON,
+ width: plan.config.width,
+ height: plan.config.height,
+ integrationId: plan.integration?.id,
});
}
if (groupedItemPlans.length === 0) continue;
@@ -982,10 +965,12 @@ export const onboardRouter = createTRPCRouter({
}
}
- const currentAppearance = await getServerSettingByKeyAsync(ctx.db, "appearance");
- const currentCulture = await getServerSettingByKeyAsync(ctx.db, "culture");
- const currentAnalytics = await getServerSettingByKeyAsync(ctx.db, "analytics");
- const currentBoardSettings = await getServerSettingByKeyAsync(ctx.db, "board");
+ const [currentAppearance, currentCulture, currentAnalytics, currentBoardSettings] = await Promise.all([
+ getServerSettingByKeyAsync(ctx.db, "appearance"),
+ getServerSettingByKeyAsync(ctx.db, "culture"),
+ getServerSettingByKeyAsync(ctx.db, "analytics"),
+ getServerSettingByKeyAsync(ctx.db, "board"),
+ ]);
const settingValues = {
appearance: SuperJSON.stringify({ ...currentAppearance, defaultColorScheme: input.server.defaultColorScheme }),
culture: SuperJSON.stringify({ ...currentCulture, defaultLocale: input.server.defaultLocale }),
diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts
index 47f15a0f1..638173216 100644
--- a/packages/api/src/router/test/board.spec.ts
+++ b/packages/api/src/router/test/board.spec.ts
@@ -288,6 +288,20 @@ describe("getAllBoards should return all boards accessable to the current user",
});
describe("getManageOverview", () => {
+ test("does not expose a public board creator email to anonymous callers", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: null });
+ const creatorId = createId();
+
+ await db.insert(users).values({ id: creatorId, name: "Board creator", email: "private@example.com" });
+ await db.insert(boards).values({ id: createId(), name: "public-overview", creatorId, isPublic: true });
+
+ const [board] = await caller.getManageOverview();
+
+ expect(board?.creator).toEqual({ id: creatorId, name: "Board creator", image: null });
+ expect(board?.creator).not.toHaveProperty("email");
+ });
+
test("returns the same accessible board set as getAllBoards", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
@@ -433,6 +447,8 @@ describe("getManageOverview", () => {
const result = await caller.getManageOverview();
expect(result).toHaveLength(1);
+ expect(result[0]?.creator).toEqual({ id: defaultCreatorId, name: null, image: null });
+ expect(result[0]?.creator).not.toHaveProperty("email");
expect(result[0]).toMatchObject({
id: boardId,
isHome: true,
diff --git a/packages/api/src/router/test/onboard.spec.ts b/packages/api/src/router/test/onboard.spec.ts
index 9c596c076..3be580a30 100644
--- a/packages/api/src/router/test/onboard.spec.ts
+++ b/packages/api/src/router/test/onboard.spec.ts
@@ -43,6 +43,8 @@ vi.mock("@homarr/common", async (importOriginal) => ({
createId: createIdMock,
}));
vi.mock("@homarr/docker", () => ({
+ createDockerSourceId: (host: string, externalId: string) =>
+ `docker:${encodeURIComponent(host)}:${encodeURIComponent(externalId)}`,
dockerLabels: { hide: "homarr.hide" },
listDiscoveredContainersAsync,
}));
@@ -267,6 +269,20 @@ describe("onboard.completeSetup", () => {
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "group" });
});
+ it("rejects an explicit board id that no longer exists", async () => {
+ const db = createDb();
+ const claimToken = await seedOnboardingAsync(db);
+ await seedBoardAsync(db, { boardId: "target" });
+
+ await expect(createCaller(db, claimToken).completeSetup(completionInput("missing"))).rejects.toMatchObject({
+ code: "NOT_FOUND",
+ message: "The selected board no longer 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("rejects a board rename that only differs by case from another board", async () => {
const db = createDb();
const claimToken = await seedOnboardingAsync(db);
@@ -899,6 +915,7 @@ describe("onboard.completeSetup", () => {
for (const layoutId of ["target-base", "target-mobile"]) {
const positions = groupedLayouts.filter((layout) => layout.layoutId === layoutId);
const containerLayout = containerLayouts.find((layout) => layout.layoutId === layoutId);
+ expect(positions.length).toBeGreaterThan(0);
expect(containerLayout).toBeDefined();
expect(Math.max(...positions.map((position) => position.yOffset + position.height))).toBeLessThanOrEqual(
containerLayout?.height ?? 0,
diff --git a/packages/api/src/router/test/onboarding-claim.spec.ts b/packages/api/src/router/test/onboarding-claim.spec.ts
index 9803ae59b..8381cc271 100644
--- a/packages/api/src/router/test/onboarding-claim.spec.ts
+++ b/packages/api/src/router/test/onboarding-claim.spec.ts
@@ -77,6 +77,8 @@ describe("onboarding claim", () => {
expect(
getOnboardingClaimTokenFromCookieHeader(`theme=dark; ${onboardingClaimCookieName}=secret-token; other=x`),
).toBe("secret-token");
+ expect(getOnboardingClaimTokenFromCookieHeader(`${onboardingClaimCookieName}=secret%2Dtoken`)).toBe("secret-token");
+ expect(getOnboardingClaimTokenFromCookieHeader(`${onboardingClaimCookieName}=%E0%A4%A`)).toBeUndefined();
expect(getOnboardingClaimTokenFromCookieHeader(`${onboardingClaimCookieName}-copy=wrong`)).toBeUndefined();
});
});
diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts
index 1545b7402..7a1ed92f4 100644
--- a/packages/api/src/router/user.ts
+++ b/packages/api/src/router/user.ts
@@ -70,6 +70,7 @@ export const userRouter = createTRPCRouter({
ownerId: userId,
position: maxPosition + 1,
};
+ const nextStep = isProviderEnabled("ldap") || isProviderEnabled("oidc") ? "group" : "setup";
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
@@ -78,7 +79,6 @@ export const userRouter = createTRPCRouter({
await transaction.insert(schema.groups).values(groupRow);
await transaction.insert(schema.groupPermissions).values({ groupId, permission: "admin" });
await transaction.insert(schema.groupMembers).values({ groupId, userId });
- const nextStep = isProviderEnabled("ldap") || isProviderEnabled("oidc") ? "group" : "setup";
await transaction.update(schema.onboarding).set({ previousStep: "user", step: nextStep });
});
},
@@ -88,7 +88,6 @@ export const userRouter = createTRPCRouter({
transaction.insert(groups).values(groupRow).run();
transaction.insert(groupPermissions).values({ groupId, permission: "admin" }).run();
transaction.insert(groupMembers).values({ groupId, userId }).run();
- const nextStep = isProviderEnabled("ldap") || isProviderEnabled("oidc") ? "group" : "setup";
transaction.update(onboarding).set({ previousStep: "user", step: nextStep }).run();
});
},
diff --git a/packages/definitions/src/integration-url-template.ts b/packages/definitions/src/integration-url-template.ts
index 298a65b15..b0887b56a 100644
--- a/packages/definitions/src/integration-url-template.ts
+++ b/packages/definitions/src/integration-url-template.ts
@@ -12,12 +12,18 @@ const normalizeBaseHost = (value: string) => {
const trimmed = value.trim();
if (!trimmed) return null;
try {
- const parsed = new URL(trimmed.includes("://") ? trimmed : `http://${trimmed}`);
+ const url = trimmed.includes("://") ? trimmed : `http://${trimmed}`;
+ const authority = url
+ .match(/^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)/iu)?.[1]
+ ?.split("@")
+ .at(-1);
+ const explicitPort = authority?.match(/^(?:\[[^\]]+\]|[^:]+):(\d+)$/u)?.[1];
+ const parsed = new URL(url);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
return {
protocol: parsed.protocol,
hostname: parsed.hostname,
- port: parsed.port ? Number(parsed.port) : undefined,
+ port: parsed.port ? Number(parsed.port) : explicitPort ? Number(explicitPort) : undefined,
pathname: parsed.pathname.replace(/\/+$/, ""),
};
} catch {
@@ -32,8 +38,7 @@ const buildUrl = (slug: string, rawHost: string, mode: UrlTemplateMode, port?: n
const modeBuilders: Record string> = {
subdomain: () => (base.hostname.startsWith("[") ? "" : `https://${slug}.${base.hostname}`),
hostPort: () => (effectivePort ? `http://${base.hostname}:${effectivePort}` : `http://${base.hostname}`),
- path: () =>
- `${base.protocol}//${base.hostname}${base.port ? `:${base.port}` : ""}${base.pathname}/${slug}`,
+ path: () => `${base.protocol}//${base.hostname}${base.port ? `:${base.port}` : ""}${base.pathname}/${slug}`,
};
return modeBuilders[mode]();
};
diff --git a/packages/definitions/src/test/integration-url-template.spec.ts b/packages/definitions/src/test/integration-url-template.spec.ts
index cd84552cb..51402d263 100644
--- a/packages/definitions/src/test/integration-url-template.spec.ts
+++ b/packages/definitions/src/test/integration-url-template.spec.ts
@@ -38,9 +38,7 @@ describe("buildIntegrationUrl", () => {
expect(buildIntegrationUrl("sonarr", "https://home.example.com/services", "path")).toBe(
"https://home.example.com/services/sonarr",
);
- expect(buildAppUrl("My Service", "home.lan/apps/", "path", 9999)).toBe(
- "http://home.lan/apps/my-service",
- );
+ expect(buildAppUrl("My Service", "home.lan/apps/", "path", 9999)).toBe("http://home.lan/apps/my-service");
});
it("replaces a supplied base port instead of producing a double port", () => {
@@ -52,6 +50,12 @@ describe("buildIntegrationUrl", () => {
);
});
+ it("preserves an explicitly supplied default port for an app", () => {
+ expect(buildAppUrl("My Service", "https://home.example.com:443/homarr", "hostPort")).toBe(
+ "http://home.example.com:443",
+ );
+ });
+
it("formats IPv6 hosts safely in host-port mode", () => {
expect(buildIntegrationUrl("sonarr", "https://[2001:db8::1]:8443/homarr", "hostPort")).toBe(
"http://[2001:db8::1]:8989",
diff --git a/packages/docker/src/discovery/index.ts b/packages/docker/src/discovery/index.ts
index 33a961350..fc45ac730 100644
--- a/packages/docker/src/discovery/index.ts
+++ b/packages/docker/src/discovery/index.ts
@@ -1,3 +1,3 @@
export { listDiscoveredContainersAsync } from "./list-discovered-containers";
-export { parseContainerLabels, type ParseContainerLabelsOptions } from "./parse-container-labels";
+export { createDockerSourceId, parseContainerLabels, type ParseContainerLabelsOptions } from "./parse-container-labels";
export type { DiscoveredService, DockerDiscoveryHostResult, DockerDiscoveryResult } from "./types";
diff --git a/packages/docker/src/discovery/parse-container-labels.spec.ts b/packages/docker/src/discovery/parse-container-labels.spec.ts
index 3fc01c4b0..22f4dbd2b 100644
--- a/packages/docker/src/discovery/parse-container-labels.spec.ts
+++ b/packages/docker/src/discovery/parse-container-labels.spec.ts
@@ -51,7 +51,7 @@ describe("parseContainerLabels", () => {
"remote:2375",
),
).toMatchObject({
- sourceId: "docker:remote:2375:container-1",
+ sourceId: "docker:remote%3A2375:container-1",
name: "Jellyfin",
group: "Media",
href: "http://jellyfin:8096",
@@ -59,6 +59,21 @@ describe("parseContainerLabels", () => {
});
});
+ it("keeps host and service identifiers unambiguous", () => {
+ const first = parseContainerLabels(
+ container({ "homarr.name": "Service", "homarr.href": "http://service", "homarr.id": "service" }),
+ "remote:2375",
+ );
+ const second = parseContainerLabels(
+ container({ "homarr.name": "Service", "homarr.href": "http://service", "homarr.id": "2375:service" }),
+ "remote",
+ );
+
+ expect(first?.sourceId).toBe("docker:remote%3A2375:service");
+ expect(second?.sourceId).toBe("docker:remote:2375%3Aservice");
+ expect(first?.sourceId).not.toBe(second?.sourceId);
+ });
+
it("keeps a labeled service without a group for root placement", () => {
expect(
parseContainerLabels(
diff --git a/packages/docker/src/discovery/parse-container-labels.ts b/packages/docker/src/discovery/parse-container-labels.ts
index 5e31ba763..56510edbd 100644
--- a/packages/docker/src/discovery/parse-container-labels.ts
+++ b/packages/docker/src/discovery/parse-container-labels.ts
@@ -31,6 +31,9 @@ const parseIntegrationKind = (value: string | undefined): IntegrationKind | unde
const parseWidgetKind = (value: string | undefined): WidgetKind | undefined =>
value && widgetKindSet.has(value) ? (value as WidgetKind) : undefined;
+export const createDockerSourceId = (host: string, externalId: string) =>
+ `docker:${encodeURIComponent(host)}:${encodeURIComponent(externalId)}`;
+
export const parseContainerLabels = (
container: Pick,
host: string,
@@ -48,7 +51,7 @@ export const parseContainerLabels = (
const externalId = readLabel(labels, dockerLabels.id) ?? container.Id;
return {
- sourceId: `docker:${host}:${externalId}`,
+ sourceId: createDockerSourceId(host, externalId),
containerId: container.Id,
host,
group,
diff --git a/packages/onboarding/src/account.tsx b/packages/onboarding/src/account.tsx
index 2fdce8be3..7a7af61fe 100644
--- a/packages/onboarding/src/account.tsx
+++ b/packages/onboarding/src/account.tsx
@@ -10,6 +10,7 @@ import { signIn } from "@homarr/auth/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useZodForm } from "@homarr/form";
import { UserCreatePasswordFields } from "@homarr/forms-collection";
+import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { groupCreateSchema } from "@homarr/validation/group";
@@ -60,17 +61,24 @@ export const AccountSetup = ({ environment }: OnboardingStudioProps) => {
const CredentialsSetup = () => {
const t = useScopedI18n("init.studio.account");
const [requiresSignIn, setRequiresSignIn] = useState(false);
+ const [submitError, setSubmitError] = useState(null);
const recoveryActionRef = useRef(null);
- const mutation = clientApi.user.initUser.useMutation();
+ const mutation = clientApi.user.initUser.useMutation({
+ onError() {
+ const message = t("unknownError");
+ setSubmitError(message);
+ showErrorNotification({ title: t("errorTitle"), message });
+ },
+ });
const form = useZodForm(userInitSchema, {
initialValues: { username: "", password: "", confirmPassword: "" },
});
const submitAsync = async (values: z.infer) => {
+ setSubmitError(null);
try {
await mutation.mutateAsync(values);
- } catch (error) {
- form.setErrors({ username: error instanceof Error ? error.message : t("unknownError") });
+ } catch {
return;
}
@@ -119,9 +127,9 @@ const CredentialsSetup = () => {
passwordInputProps={{ ...form.getInputProps("password"), autoComplete: "new-password" }}
confirmPasswordInputProps={{ ...form.getInputProps("confirmPassword"), autoComplete: "new-password" }}
/>
- {mutation.error ? (
+ {submitError ? (
- {mutation.error.message}
+ {submitError}
) : null}
}>
@@ -135,16 +143,27 @@ const CredentialsSetup = () => {
const ExternalGroupSetup = () => {
const t = useScopedI18n("init.studio.externalGroup");
+ const [submitError, setSubmitError] = useState(null);
const mutation = clientApi.group.createInitialExternalGroup.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/init");
},
+ onError() {
+ const message = t("unknownError");
+ setSubmitError(message);
+ showErrorNotification({ title: t("errorTitle"), message });
+ },
});
const form = useZodForm(groupCreateSchema, { initialValues: { name: "" } });
return (
-