From 07770e862c20a006ea1b5a4f086e4237ce81bc53 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Thu, 13 Aug 2026 17:45:05 +0200 Subject: [PATCH] fix: harden onboarding completion --- .../after-the-installation.mdx | 8 +- apps/nextjs/src/app/[locale]/init/page.tsx | 41 +- apps/nextjs/src/app/[locale]/layout.tsx | 8 +- .../new/_integration-new-form.tsx | 38 +- .../nextjs/src/app/api/backup/import/route.ts | 196 ++++- .../app/api/backup/test/import-route.spec.ts | 258 +++++++ .../backup/database-restore-flow.spec.ts | 75 ++ .../backup/database-restore-flow.tsx | 179 ++++- .../backup/restore-progress-panel.spec.tsx | 99 +++ .../backup/restore-progress-panel.tsx | 204 ++--- e2e/manage-collections.spec.ts | 142 ++++ e2e/onboarding.spec.ts | 371 +++++++-- packages/api/src/router/board.ts | 145 +++- packages/api/src/router/group.ts | 22 +- .../router/onboard/capability-probes.spec.ts | 70 ++ .../src/router/onboard/capability-probes.ts | 80 ++ .../api/src/router/onboard/onboard-queries.ts | 7 +- .../api/src/router/onboard/onboard-router.ts | 400 +++++----- packages/api/src/router/test/board.spec.ts | 166 +++- .../src/router/test/group-onboarding.spec.ts | 37 + packages/api/src/router/test/onboard.spec.ts | 207 ++++- .../src/integration-url-template.ts | 12 +- packages/definitions/src/integration.ts | 18 +- .../definitions/src/test/api-key-url.spec.ts | 26 +- .../src/test/integration-url-template.spec.ts | 9 + .../definitions/src/widget-integration-map.ts | 6 + .../list-discovered-containers.spec.ts | 8 +- .../discovery/list-discovered-containers.ts | 42 +- .../onboarding/src/account-recovery.spec.ts | 14 + packages/onboarding/src/account-recovery.ts | 7 + packages/onboarding/src/account.tsx | 42 +- packages/onboarding/src/claim-state.spec.ts | 26 + packages/onboarding/src/claim-state.ts | 14 + .../src/discovery-selection.spec.ts | 31 + .../onboarding/src/discovery-selection.ts | 21 + packages/onboarding/src/finish.tsx | 4 +- .../src/onboarding-studio.module.css | 44 +- packages/onboarding/src/onboarding-studio.tsx | 24 +- packages/onboarding/src/setup-studio.tsx | 715 ++++++++++++------ packages/onboarding/src/types.ts | 2 - packages/onboarding/src/welcome.tsx | 2 +- packages/translation/src/lang/en.json | 47 +- packages/validation/src/onboarding.spec.ts | 27 +- packages/validation/src/onboarding.ts | 109 ++- 44 files changed, 3003 insertions(+), 1000 deletions(-) create mode 100644 apps/nextjs/src/app/api/backup/test/import-route.spec.ts create mode 100644 apps/nextjs/src/components/backup/database-restore-flow.spec.ts create mode 100644 apps/nextjs/src/components/backup/restore-progress-panel.spec.tsx create mode 100644 e2e/manage-collections.spec.ts create mode 100644 packages/api/src/router/onboard/capability-probes.spec.ts create mode 100644 packages/api/src/router/onboard/capability-probes.ts create mode 100644 packages/api/src/router/test/group-onboarding.spec.ts create mode 100644 packages/onboarding/src/account-recovery.spec.ts create mode 100644 packages/onboarding/src/account-recovery.ts create mode 100644 packages/onboarding/src/claim-state.spec.ts create mode 100644 packages/onboarding/src/claim-state.ts create mode 100644 packages/onboarding/src/discovery-selection.spec.ts create mode 100644 packages/onboarding/src/discovery-selection.ts diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index 198439919..cd9432a1d 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -45,12 +45,12 @@ cannot change credentials or integrations during that setup. The studio keeps related choices together: -1. **Essentials** sets the default language, color scheme, analytics preference, and the usual host, IP address, or domain used by your services. +1. **Essentials** sets the default language, color scheme, analytics preference, and the usual host, IP address, domain, or reverse-proxy path used by your services. URL suggestions can use separate ports, subdomains, or one path per service. 2. **Discover** checks the actual database, Docker-compatible sockets, Kubernetes configuration, Workshop, and Assistant capabilities. Unavailable sources never block manual setup. -3. **Connect** combines explicit `homarr.*` or Homepage Docker labels with image matching. Select several integrations and apps at once, review every URL, and use the service-specific link to open its API-key settings when one is available. -4. **Board** previews the protected Mobile and Base layouts, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels. +3. **Connect** combines explicit `homarr.*` or Homepage Docker labels with image matching. Select several integrations and apps at once, review every URL, and use the service-specific link to open its API-key settings when one is available. If a container has no published port or URL label, add its HTTP or HTTPS address before importing it. +4. **Board** previews the protected Mobile and Base layouts, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels. A fast setup still adds the integration-free Clock, Weather, and Bookmarks starter widgets. 5. **Extend** explains and optionally configures Homarr Assistant, the community Workshop, and the separate Homarr MCP endpoint. -6. **Review** tests and saves the selected connections, creates the chosen apps and widgets, and configures the exact first board without clearing unrelated data. +6. **Review** tests the selected connections, then saves integrations, apps, widgets, settings, and the exact first board together. If any part fails, the database changes are rolled back and your form stays available to correct. Docker discovery reports each configured host separately. Results from reachable hosts remain selectable if another socket is unavailable, which is useful for remote Docker, rootless Docker, Synology, and mixed deployments. On Kubernetes or diff --git a/apps/nextjs/src/app/[locale]/init/page.tsx b/apps/nextjs/src/app/[locale]/init/page.tsx index fbf55ff6a..d1b4ffec1 100644 --- a/apps/nextjs/src/app/[locale]/init/page.tsx +++ b/apps/nextjs/src/app/[locale]/init/page.tsx @@ -1,5 +1,6 @@ import { headers } from "next/headers"; +import { getOnboardingClaimTokenFromCookieHeader, isOnboardingClaimValidAsync } from "@homarr/api/onboarding-claim"; import { normalizeOnboardingStep } from "@homarr/api/onboarding-step"; import { auth } from "@homarr/auth/next"; import { isProviderEnabled } from "@homarr/auth/server"; @@ -16,28 +17,38 @@ import { DatabaseRestoreFlow } from "~/components/backup"; import { env } from "~/env"; export default async function InitPage() { - const [state, session, requestHeaders, defaultGroup, firstUser, availableBoards] = await Promise.all([ + const [state, session, requestHeaders, firstUser] = await Promise.all([ db.query.onboarding.findFirst(), auth(), headers(), - db.query.groups.findFirst({ - where: eq(groups.name, everyoneGroup), - columns: { homeBoardId: true }, - }), db.query.users.findFirst({ columns: { id: true } }), - db.query.boards.findMany({ columns: { id: true, name: true } }), ]); + const canConfigurePrivileged = session?.user.permissions.includes("admin") ?? false; + const onboardingClaim = getOnboardingClaimTokenFromCookieHeader(requestHeaders.get("cookie")); + const canReadSetupContext = + canConfigurePrivileged || (!firstUser && (await isOnboardingClaimValidAsync(db, onboardingClaim))); + const [defaultGroup, availableBoards] = canReadSetupContext + ? await Promise.all([ + db.query.groups.findFirst({ + where: eq(groups.name, everyoneGroup), + columns: { homeBoardId: true }, + }), + db.query.boards.findMany({ columns: { id: true, name: true } }), + ]) + : [undefined, []]; const homeBoardId = defaultGroup?.homeBoardId; const homeBoard = availableBoards.find((board) => board.id === homeBoardId); const initialBoard = homeBoard ?? (availableBoards.length === 1 ? availableBoards[0] : null); const currentStep = normalizeOnboardingStep(state?.step); const databaseDriver = dbEnv.DRIVER === "better-sqlite3" ? "sqlite" : dbEnv.DRIVER === "mysql2" ? "mysql" : "postgresql"; - const { workshopApiUrl, workshopWebUrl } = resolveHomarrUrlConfig({ - homarrWebsiteUrl: env.HOMARR_WEBSITE_URL, - workshopApiUrl: env.WORKSHOP_API_URL, - workshopWebUrl: env.WORKSHOP_WEB_URL, - }); + const { workshopApiUrl, workshopWebUrl } = canReadSetupContext + ? resolveHomarrUrlConfig({ + homarrWebsiteUrl: env.HOMARR_WEBSITE_URL, + workshopApiUrl: env.WORKSHOP_API_URL, + workshopWebUrl: env.WORKSHOP_WEB_URL, + }) + : { workshopApiUrl: "", workshopWebUrl: "" }; const baseUrl = extractBaseUrlFromHeaders(requestHeaders); return ( @@ -45,15 +56,13 @@ export default async function InitPage() { environment={{ currentStep, databaseDriver, - credentialsEnabled: isProviderEnabled("credentials"), externalAuthEnabled: isProviderEnabled("ldap") || isProviderEnabled("oidc"), - dockerConfigured: dockerEnv.ENABLE_DOCKER, - kubernetesConfigured: dockerEnv.ENABLE_KUBERNETES, - workshopEnabled: true, + dockerConfigured: canReadSetupContext && dockerEnv.ENABLE_DOCKER, + kubernetesConfigured: canReadSetupContext && dockerEnv.ENABLE_KUBERNETES, workshopApiUrl, workshopUrl: workshopWebUrl, mcpEndpoint: `${baseUrl}/api/mcp`, - canConfigurePrivileged: session?.user.permissions.includes("admin") ?? false, + canConfigurePrivileged, hasUsers: Boolean(firstUser), initialBoard: initialBoard ?? null, availableBoards, diff --git a/apps/nextjs/src/app/[locale]/layout.tsx b/apps/nextjs/src/app/[locale]/layout.tsx index b2cb2f731..401259c06 100644 --- a/apps/nextjs/src/app/[locale]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/layout.tsx @@ -173,8 +173,12 @@ export default async function Layout(props: { > - - + {session ? ( + <> + + + + ) : null} diff --git a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx index d3a984cfb..3df77454e 100644 --- a/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/integrations/new/_integration-new-form.tsx @@ -51,8 +51,6 @@ interface NewIntegrationFormProps { initialName?: string; onSuccess: () => void; onCancel?: () => void; - onSkip?: () => void; - isOnboarding?: boolean; } const formSchema = integrationCreateSchema.omit({ kind: true, app: true }).and( @@ -69,14 +67,12 @@ export const NewIntegrationForm = ({ initialName, onSuccess, onCancel, - onSkip, - isOnboarding = false, }: NewIntegrationFormProps) => { const t = useI18n(); const secretKinds = getAllSecretKindOptions(kind); const hasUrlSecret = secretKinds.some((kinds) => kinds.includes("url")); const { data: session } = useSession(); - const canCreateApps = !isOnboarding && (session?.user.permissions.includes("app-create") ?? false); + const canCreateApps = session?.user.permissions.includes("app-create") ?? false; let url = initialUrl ?? getIntegrationDefaultUrl(kind) ?? ""; if (hasUrlSecret) { @@ -90,9 +86,9 @@ export const NewIntegrationForm = ({ kind, value: "", })), - attemptSearchEngineCreation: !isOnboarding, - hasApp: !isOnboarding, - appHref: isOnboarding ? null : url, + attemptSearchEngineCreation: true, + hasApp: true, + appHref: url, appId: null, }, }); @@ -104,13 +100,6 @@ export const NewIntegrationForm = ({ await utils.integration.invalidate(); }, }); - const { mutateAsync: createOnboardingIntegrationAsync, isPending: isOnboardingCreatePending } = - clientApi.onboard.createIntegration.useMutation({ - async onSuccess() { - await utils.integration.invalidate(); - }, - }); - const isPending = isCreatePending || isOnboardingCreatePending; const [error, setError] = useState(null); const handleSubmitAsync = async ({ appId, appHref, hasApp, ...values }: FormType) => { @@ -143,14 +132,6 @@ export const NewIntegrationForm = ({ }); }; - if (isOnboarding) { - await createOnboardingIntegrationAsync( - { kind, name: values.name, url, secrets: values.secrets }, - { onSuccess: onMutationSuccess, onError: onMutationError }, - ); - return; - } - const hasCustomHref = appHref !== null && appHref.trim().length >= 1; const app = hasApp @@ -206,7 +187,7 @@ export const NewIntegrationForm = ({ {error !== null && } - {!isOnboarding && supportsSearchEngine && ( + {supportsSearchEngine && ( )} - {!isOnboarding && } + {onCancel ? ( @@ -228,12 +209,7 @@ export const NewIntegrationForm = ({ {t("common.action.backToOverview")} )} - {onSkip && ( - - )} - diff --git a/apps/nextjs/src/app/api/backup/import/route.ts b/apps/nextjs/src/app/api/backup/import/route.ts index e244406ff..92f81dd23 100644 --- a/apps/nextjs/src/app/api/backup/import/route.ts +++ b/apps/nextjs/src/app/api/backup/import/route.ts @@ -24,6 +24,101 @@ import { findMigrationsFolder } from "../shared"; const REQUIRED_ZIP_ENTRIES = ["db.sqlite", "metadata.json"] as const; const ALGORITHM = "aes-256-cbc"; const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/; +const MEBIBYTE = 1024 * 1024; +const MAX_COMPRESSED_BACKUP_BYTES = 256 * MEBIBYTE; +const MAX_MULTIPART_REQUEST_BYTES = MAX_COMPRESSED_BACKUP_BYTES + MEBIBYTE; +const MAX_UNCOMPRESSED_DATABASE_BYTES = 512 * MEBIBYTE; +const MAX_UNCOMPRESSED_ARCHIVE_BYTES = MAX_UNCOMPRESSED_DATABASE_BYTES + 2 * MEBIBYTE; +const MAX_METADATA_BYTES = MEBIBYTE; +const MAX_ENCRYPTION_KEY_BYTES = 1024; +const RESTART_DELAY_MS = 500; + +let restoreInProgress = false; + +class BackupTooLargeError extends Error {} + +const readBoundedRequestBodyAsync = async (request: Request): Promise> => { + const declaredLength = request.headers.get("content-length"); + if (declaredLength !== null) { + const parsedLength = Number(declaredLength); + if (!Number.isSafeInteger(parsedLength) || parsedLength < 0) { + throw new Error("Invalid Content-Length header"); + } + if (parsedLength > MAX_MULTIPART_REQUEST_BYTES) { + throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit"); + } + } + + if (!request.body) return new Uint8Array(); + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + totalBytes += value.byteLength; + if (totalBytes > MAX_MULTIPART_REQUEST_BYTES) { + await reader.cancel(); + throw new BackupTooLargeError("Backup upload exceeds the 256 MB limit"); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const body = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return body; +}; + +const parseBoundedFormDataAsync = async (request: Request): Promise => { + const contentType = request.headers.get("content-type"); + if (!contentType) throw new Error("Missing Content-Type header"); + + const body = await readBoundedRequestBodyAsync(request); + return new Response(body, { headers: { "content-type": contentType } }).formData(); +}; + +const assertArchiveSizeLimits = (zip: AdmZip) => { + const entries = zip.getEntries(); + let totalSize = 0; + + for (const entry of entries) { + if (entry.isDirectory) continue; + const entrySize = entry.header.size; + if (!Number.isSafeInteger(entrySize) || entrySize < 0) { + throw new Error(`Invalid uncompressed size for ${entry.entryName}`); + } + totalSize += entrySize; + if (totalSize > MAX_UNCOMPRESSED_ARCHIVE_BYTES) { + throw new BackupTooLargeError("Uncompressed backup archive exceeds the 514 MB limit"); + } + } + + const dbEntry = zip.getEntry("db.sqlite"); + if (dbEntry && dbEntry.header.size > MAX_UNCOMPRESSED_DATABASE_BYTES) { + throw new BackupTooLargeError("Uncompressed SQLite database exceeds the 512 MB limit"); + } + + const metadataEntry = zip.getEntry("metadata.json"); + if (metadataEntry && metadataEntry.header.size > MAX_METADATA_BYTES) { + throw new BackupTooLargeError("Backup metadata exceeds the 1 MB limit"); + } + + const encryptionKeyEntry = zip.getEntry("encryption-key.txt"); + if (encryptionKeyEntry && encryptionKeyEntry.header.size > MAX_ENCRYPTION_KEY_BYTES) { + throw new BackupTooLargeError("Backup encryption key entry is too large"); + } +}; const reEncryptSecrets = (tempDb: InstanceType, importedKeyHex: string) => { const currentKeyHex = env.SECRET_ENCRYPTION_KEY; @@ -105,41 +200,62 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Database path not configured" }, { status: 500 }); } - let formData: FormData; - try { - formData = await req.formData(); - } catch { - return NextResponse.json({ error: "Invalid form data" }, { status: 400 }); - } - - const file = formData.get("file"); - if (!(file instanceof File)) { - return NextResponse.json({ error: "No file provided" }, { status: 400 }); - } - - const arrayBuffer = await file.arrayBuffer(); - let zip: AdmZip; - try { - zip = new AdmZip(Buffer.from(arrayBuffer)); - } catch { - return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 }); - } - - const entries = zip.getEntries().map((e) => e.entryName); - const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !entries.includes(name)); - if (missingEntries.length > 0) { - return NextResponse.json({ error: `Invalid backup: missing ${missingEntries.join(", ")}` }, { status: 400 }); - } - const migrationsFolder = findMigrationsFolder(); if (!migrationsFolder) { return NextResponse.json({ error: "Migration files not found" }, { status: 500 }); } - const tempPath = path.join(path.dirname(dbPath), "db.import.sqlite"); + if (restoreInProgress) { + return NextResponse.json({ error: "A database restore is already in progress" }, { status: 409 }); + } + + restoreInProgress = true; + let tempDirectory: string | null = null; let tempDb: InstanceType | null = null; try { + let formData: FormData; + try { + formData = await parseBoundedFormDataAsync(req); + } catch (error) { + if (error instanceof BackupTooLargeError) { + return NextResponse.json({ error: error.message }, { status: 413 }); + } + return NextResponse.json({ error: "Invalid form data" }, { status: 400 }); + } + + const file = formData.get("file"); + if (!(file instanceof File)) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }); + } + if (file.size > MAX_COMPRESSED_BACKUP_BYTES) { + return NextResponse.json({ error: "Backup upload exceeds the 256 MB limit" }, { status: 413 }); + } + + let zip: AdmZip; + try { + zip = new AdmZip(Buffer.from(await file.arrayBuffer())); + } catch { + return NextResponse.json({ error: "Invalid ZIP file" }, { status: 400 }); + } + + const entries = zip.getEntries().map((entry) => entry.entryName); + const missingEntries = REQUIRED_ZIP_ENTRIES.filter((name) => !entries.includes(name)); + if (missingEntries.length > 0) { + return NextResponse.json({ error: `Invalid backup: missing ${missingEntries.join(", ")}` }, { status: 400 }); + } + + try { + assertArchiveSizeLimits(zip); + } catch (error) { + if (error instanceof BackupTooLargeError) { + return NextResponse.json({ error: error.message }, { status: 413 }); + } + throw error; + } + + tempDirectory = fs.mkdtempSync(path.join(path.dirname(dbPath), ".homarr-restore-")); + const tempPath = path.join(tempDirectory, "db.sqlite"); const dbEntry = zip.getEntry("db.sqlite"); if (!dbEntry) { return NextResponse.json({ error: "Invalid backup: missing db.sqlite" }, { status: 400 }); @@ -187,23 +303,33 @@ export async function POST(req: Request) { } } - setTimeout(() => { + const restartTimer = setTimeout(() => { console.log("Database restored, restarting server..."); process.exit(0); - }, 500); + }, RESTART_DELAY_MS); + restartTimer.unref(); - return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." }); + return NextResponse.json({ + success: true, + message: "Database restored. Server is restarting...", + restartAfterMs: RESTART_DELAY_MS, + }); } catch (error) { console.error("[backup/import] Restore failed:", error); const message = error instanceof Error ? error.message : "Unknown error"; return NextResponse.json({ error: `Restore failed: ${message}` }, { status: 500 }); } finally { - tempDb?.close(); - if (fs.existsSync(tempPath)) { + restoreInProgress = false; + try { + tempDb?.close(); + } catch (error) { + console.error("[backup/import] Failed to close temporary database:", error); + } + if (tempDirectory) { try { - fs.unlinkSync(tempPath); - } catch { - // temp file may have been renamed already + fs.rmSync(tempDirectory, { recursive: true, force: true }); + } catch (error) { + console.error("[backup/import] Failed to remove temporary restore directory:", error); } } } diff --git a/apps/nextjs/src/app/api/backup/test/import-route.spec.ts b/apps/nextjs/src/app/api/backup/test/import-route.spec.ts new file mode 100644 index 000000000..a8e0a3bdb --- /dev/null +++ b/apps/nextjs/src/app/api/backup/test/import-route.spec.ts @@ -0,0 +1,258 @@ +// @vitest-environment node + +import fs from "fs"; +import os from "os"; +import path from "path"; + +import AdmZip from "adm-zip"; +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { DB_CASING } from "@homarr/core/infrastructure/db/constants"; + +const routeMocks = vi.hoisted(() => ({ + auth: vi.fn(), + claimAllowed: vi.fn(), + onboardingFindFirst: vi.fn(), + dbEnv: { + DRIVER: "better-sqlite3", + URL: "", + }, + commonEnv: { + SECRET_ENCRYPTION_KEY: "1".repeat(64), + }, +})); + +vi.mock("@homarr/auth/next", () => ({ auth: routeMocks.auth })); +vi.mock("@homarr/auth/server", () => ({ isProviderEnabled: () => false })); +vi.mock("@homarr/api/onboarding-claim", () => ({ + getOnboardingClaimTokenFromCookieHeader: () => "test-claim", + isClaimOnlyOnboardingAccessAllowedAsync: routeMocks.claimAllowed, +})); +vi.mock("@homarr/common/env", () => ({ env: routeMocks.commonEnv })); +vi.mock("@homarr/core/infrastructure/db/env", () => ({ dbEnv: routeMocks.dbEnv })); +vi.mock("@homarr/db", () => ({ + db: { + query: { + onboarding: { + findFirst: routeMocks.onboardingFindFirst, + }, + }, + }, +})); + +import { POST } from "../import/route"; + +const MEBIBYTE = 1024 * 1024; +const MIGRATIONS_FOLDER = "./packages/db/migrations/sqlite"; + +const createMigratedDatabase = (databasePath: string, boardId: string, boardName: string) => { + const sqlite = new Database(databasePath); + migrate(drizzle(sqlite, { casing: DB_CASING }), { migrationsFolder: MIGRATIONS_FOLDER }); + sqlite.exec(` + INSERT INTO "board" ( + "id", "name", "is_public", "primary_color", "secondary_color", "opacity", "item_radius", + "background_image_attachment", "background_image_repeat", "background_image_size", "disable_status" + ) VALUES ( + '${boardId}', '${boardName}', 0, '#fa5252', '#fd7e14', 100, 'lg', 'fixed', 'no-repeat', 'cover', 0 + ) + `); + sqlite.close(); +}; + +const createBackup = (directory: string, name: string, boardName: string) => { + const sourcePath = path.join(directory, `${name}.sqlite`); + createMigratedDatabase(sourcePath, `board-${name}`, boardName); + + const zip = new AdmZip(); + zip.addFile("db.sqlite", fs.readFileSync(sourcePath)); + zip.addFile( + "metadata.json", + Buffer.from( + JSON.stringify({ + dbDialect: "sqlite", + encryptionKey: routeMocks.commonEnv.SECRET_ENCRYPTION_KEY, + }), + ), + ); + return zip.toBuffer(); +}; + +const createImportRequest = (backup: Buffer) => { + const formData = new FormData(); + formData.set("file", new File([Uint8Array.from(backup)], "homarr-backup.zip", { type: "application/zip" })); + return new Request("http://homarr.test/api/backup/import", { + method: "POST", + body: formData, + }); +}; + +const readBoardNames = (databasePath: string) => { + const sqlite = new Database(databasePath, { readonly: true }); + const rows = sqlite.prepare('SELECT "name" FROM "board" ORDER BY "name"').all() as { name: string }[]; + sqlite.close(); + return rows.map((row) => row.name); +}; + +describe("POST /api/backup/import", () => { + let temporaryDirectory: string; + let activeDatabasePath: string; + + beforeEach(() => { + vi.useFakeTimers(); + temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "homarr-import-route-test-")); + activeDatabasePath = path.join(temporaryDirectory, "db.sqlite"); + routeMocks.dbEnv.DRIVER = "better-sqlite3"; + routeMocks.dbEnv.URL = activeDatabasePath; + routeMocks.auth.mockResolvedValue({ user: { permissions: ["admin"] } }); + routeMocks.claimAllowed.mockResolvedValue(false); + routeMocks.onboardingFindFirst.mockResolvedValue({ step: "start" }); + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + }); + + it("accepts the legacy import step through normalized onboarding claim access", async () => { + createMigratedDatabase(activeDatabasePath, "board-current", "Current board"); + const backup = createBackup(temporaryDirectory, "legacy", "Restored board"); + routeMocks.auth.mockResolvedValue(null); + routeMocks.claimAllowed.mockResolvedValue(true); + routeMocks.onboardingFindFirst.mockResolvedValue({ step: "import" }); + + const response = await POST(createImportRequest(backup)); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ success: true, restartAfterMs: 500 }); + expect(readBoardNames(activeDatabasePath)).toEqual(["Restored board"]); + }); + + it("rejects an oversized declared upload before reading its body", async () => { + const originalDatabase = Buffer.from("active database remains untouched"); + fs.writeFileSync(activeDatabasePath, originalDatabase); + const request = new Request("http://homarr.test/api/backup/import", { + method: "POST", + headers: { + "content-length": String(258 * MEBIBYTE), + "content-type": "multipart/form-data; boundary=oversized", + }, + body: "--oversized--\r\n", + }); + + const response = await POST(request); + + expect(response.status).toBe(413); + expect(fs.readFileSync(activeDatabasePath)).toEqual(originalDatabase); + }); + + it("rejects an oversized uncompressed SQLite entry without replacing the active database", async () => { + const originalDatabase = Buffer.from("active database remains untouched"); + fs.writeFileSync(activeDatabasePath, originalDatabase); + const zip = new AdmZip(); + zip.addFile("db.sqlite", Buffer.from("small compressed payload")); + zip.addFile("metadata.json", Buffer.from('{"dbDialect":"sqlite"}')); + const databaseEntry = zip.getEntry("db.sqlite"); + if (!databaseEntry) throw new Error("Expected database entry"); + databaseEntry.header.size = 513 * MEBIBYTE; + + const response = await POST(createImportRequest(zip.toBuffer())); + + expect(response.status).toBe(413); + expect(fs.readFileSync(activeDatabasePath)).toEqual(originalDatabase); + }); + + it("rejects an oversized uncompressed archive before extracting any entry", async () => { + const originalDatabase = Buffer.from("active database remains untouched"); + fs.writeFileSync(activeDatabasePath, originalDatabase); + const zip = new AdmZip(); + zip.addFile("db.sqlite", Buffer.from("small compressed payload")); + zip.addFile("metadata.json", Buffer.from('{"dbDialect":"sqlite"}')); + zip.addFile("unexpected.bin", Buffer.from("small compressed payload")); + const unexpectedEntry = zip.getEntry("unexpected.bin"); + if (!unexpectedEntry) throw new Error("Expected oversized test entry"); + unexpectedEntry.header.size = 515 * MEBIBYTE; + + const response = await POST(createImportRequest(zip.toBuffer())); + + expect(response.status).toBe(413); + expect(fs.readFileSync(activeDatabasePath)).toEqual(originalDatabase); + }); + + it("uses and cleans a unique temporary directory for every restore", async () => { + createMigratedDatabase(activeDatabasePath, "board-current", "Current board"); + const createdDirectories: string[] = []; + const makeTemporaryDirectory = fs.mkdtempSync.bind(fs); + vi.spyOn(fs, "mkdtempSync").mockImplementation((prefix, options) => { + const directory = makeTemporaryDirectory(prefix, options); + createdDirectories.push(directory); + return directory; + }); + + const firstResponse = await POST(createImportRequest(createBackup(temporaryDirectory, "first", "First restore"))); + const secondResponse = await POST( + createImportRequest(createBackup(temporaryDirectory, "second", "Second restore")), + ); + + expect(firstResponse.status).toBe(200); + expect(secondResponse.status).toBe(200); + expect(new Set(createdDirectories).size).toBe(2); + expect(createdDirectories.every((directory) => !fs.existsSync(directory))).toBe(true); + expect(readBoardNames(activeDatabasePath)).toEqual(["Second restore"]); + }); + + it("keeps a successful restore response when temporary directory cleanup fails", async () => { + createMigratedDatabase(activeDatabasePath, "board-current", "Current board"); + const backup = createBackup(temporaryDirectory, "cleanup-failure", "Restored despite cleanup failure"); + const removeDirectory = fs.rmSync.bind(fs); + const cleanupError = new Error("simulated cleanup failure"); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined); + vi.spyOn(fs, "rmSync").mockImplementation((target, options) => { + if (String(target).includes(".homarr-restore-")) throw cleanupError; + return removeDirectory(target, options); + }); + + const response = await POST(createImportRequest(backup)); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ success: true }); + expect(readBoardNames(activeDatabasePath)).toEqual(["Restored despite cleanup failure"]); + expect(errorLog).toHaveBeenCalledWith( + "[backup/import] Failed to remove temporary restore directory:", + cleanupError, + ); + }); + + it("rejects a concurrent restore while the first request owns the restore lock", async () => { + createMigratedDatabase(activeDatabasePath, "board-current", "Current board"); + const backup = createBackup(temporaryDirectory, "concurrent", "Concurrent restore"); + const originalArrayBuffer = File.prototype.arrayBuffer; + let signalStarted!: () => void; + let releaseFirstRequest!: () => void; + const started = new Promise((resolve) => { + signalStarted = resolve; + }); + const release = new Promise((resolve) => { + releaseFirstRequest = resolve; + }); + vi.spyOn(File.prototype, "arrayBuffer").mockImplementationOnce(async function (this: File) { + signalStarted(); + await release; + return originalArrayBuffer.call(this); + }); + + const firstRestore = POST(createImportRequest(backup)); + await started; + const concurrentResponse = await POST(createImportRequest(backup)); + releaseFirstRequest(); + const firstResponse = await firstRestore; + + expect(concurrentResponse.status).toBe(409); + expect(firstResponse.status).toBe(200); + expect(readBoardNames(activeDatabasePath)).toEqual(["Concurrent restore"]); + }); +}); diff --git a/apps/nextjs/src/components/backup/database-restore-flow.spec.ts b/apps/nextjs/src/components/backup/database-restore-flow.spec.ts new file mode 100644 index 000000000..ab9114813 --- /dev/null +++ b/apps/nextjs/src/components/backup/database-restore-flow.spec.ts @@ -0,0 +1,75 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { waitForServerReadinessAsync } from "./database-restore-flow"; + +describe("waitForServerReadinessAsync", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("starts checking after the server-provided restart delay and returns when ready", async () => { + const controller = new AbortController(); + const requestReady = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); + const readiness = waitForServerReadinessAsync({ + restartAfterMs: 500, + signal: controller.signal, + timeoutMs: 5_000, + pollIntervalMs: 250, + requestReady, + }); + + await vi.advanceTimersByTimeAsync(499); + expect(requestReady).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(requestReady).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(250); + await expect(readiness).resolves.toBe("ready"); + expect(requestReady).toHaveBeenCalledTimes(2); + }); + + it("times out even when a readiness request never settles on its own", async () => { + const controller = new AbortController(); + const requestReady = vi.fn( + (signal: AbortSignal) => + new Promise((resolve) => { + signal.addEventListener("abort", () => resolve(false), { once: true }); + }), + ); + const readiness = waitForServerReadinessAsync({ + restartAfterMs: 0, + signal: controller.signal, + timeoutMs: 1_000, + pollIntervalMs: 100, + requestTimeoutMs: 400, + requestReady, + }); + + await vi.advanceTimersByTimeAsync(1_001); + + await expect(readiness).resolves.toBe("timedOut"); + expect(requestReady).toHaveBeenCalledTimes(3); + }); + + it("stops cleanly when the restore flow is unmounted", async () => { + const controller = new AbortController(); + const requestReady = vi.fn().mockResolvedValue(false); + const readiness = waitForServerReadinessAsync({ + restartAfterMs: 500, + signal: controller.signal, + requestReady, + }); + + controller.abort(); + + await expect(readiness).resolves.toBe("aborted"); + expect(requestReady).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/nextjs/src/components/backup/database-restore-flow.tsx b/apps/nextjs/src/components/backup/database-restore-flow.tsx index 83d8f9f3f..5b4d7f5c8 100644 --- a/apps/nextjs/src/components/backup/database-restore-flow.tsx +++ b/apps/nextjs/src/components/backup/database-restore-flow.tsx @@ -1,12 +1,12 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Alert, Button, Group, Loader, rem, Stack, Text } from "@mantine/core"; import type { FileWithPath } from "@mantine/dropzone"; import { Dropzone, MIME_TYPES } from "@mantine/dropzone"; import { IconAlertTriangle, IconArrowRight, IconFileZip, IconUpload, IconX } from "@tabler/icons-react"; -import "@mantine/dropzone/styles.css"; +import "@mantine/dropzone/styles.css"; // oxlint-disable-line import/no-unassigned-import import { useScopedI18n } from "@homarr/translation/client"; @@ -22,16 +22,102 @@ interface DatabaseRestoreFlowProps { onRestoreComplete?: () => void; } +const DEFAULT_RESTART_DELAY_MS = 500; +const RESTART_READINESS_TIMEOUT_MS = 45_000; +const RESTART_POLL_INTERVAL_MS = 750; +const RESTART_REQUEST_TIMEOUT_MS = 5_000; + +export type ServerReadinessResult = "ready" | "timedOut" | "aborted"; + +interface WaitForServerReadinessOptions { + restartAfterMs: number; + signal: AbortSignal; + timeoutMs?: number; + pollIntervalMs?: number; + requestTimeoutMs?: number; + requestReady?: (signal: AbortSignal) => Promise; +} + +const waitAsync = (durationMs: number, signal: AbortSignal) => + new Promise((resolve) => { + if (signal.aborted) { + resolve(false); + return; + } + + const timer = window.setTimeout(() => { + signal.removeEventListener("abort", handleAbort); + resolve(true); + }, durationMs); + const handleAbort = () => { + window.clearTimeout(timer); + resolve(false); + }; + signal.addEventListener("abort", handleAbort, { once: true }); + }); + +const requestServerReadinessAsync = async (signal: AbortSignal) => { + const response = await fetch("/api/health/ready", { cache: "no-store", signal }); + return response.ok; +}; + +export const waitForServerReadinessAsync = async ({ + restartAfterMs, + signal, + timeoutMs = RESTART_READINESS_TIMEOUT_MS, + pollIntervalMs = RESTART_POLL_INTERVAL_MS, + requestTimeoutMs = RESTART_REQUEST_TIMEOUT_MS, + requestReady = requestServerReadinessAsync, +}: WaitForServerReadinessOptions): Promise => { + if (!(await waitAsync(Math.max(0, restartAfterMs), signal))) return "aborted"; + + const deadline = Date.now() + Math.max(0, timeoutMs); + while (!signal.aborted) { + const remainingMs = deadline - Date.now(); + if (remainingMs < 0) return "timedOut"; + + const requestController = new AbortController(); + const abortRequest = () => requestController.abort(); + signal.addEventListener("abort", abortRequest, { once: true }); + const requestTimer = window.setTimeout(abortRequest, Math.min(requestTimeoutMs, remainingMs)); + + let ready = false; + try { + ready = await requestReady(requestController.signal); + } catch { + // The server is expected to be temporarily unreachable while it restarts. + } finally { + window.clearTimeout(requestTimer); + signal.removeEventListener("abort", abortRequest); + } + + if (signal.aborted) return "aborted"; + if (ready) return "ready"; + + const delayMs = Math.min(pollIntervalMs, deadline - Date.now()); + if (delayMs <= 0) return "timedOut"; + if (!(await waitAsync(delayMs, signal))) return "aborted"; + } + + return "aborted"; +}; + export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: DatabaseRestoreFlowProps) => { const t = useScopedI18n("management.page.tool.backup.restore"); const [file, setFile] = useState(null); const [step, setStep] = useState("upload"); const [importError, setImportError] = useState(null); - const apiDoneRef = useRef(false); - const animDoneRef = useRef(false); - const apiErrorRef = useRef(null); + const [restoreStatus, setRestoreStatus] = useState<"restoring" | "restarting" | "timedOut">("restoring"); + const restartControllerRef = useRef(null); const { analysis, loading, error: analysisError, migrationProgress, analyzeFile, reset } = useBackupAnalysis(); + useEffect( + () => () => { + restartControllerRef.current?.abort(); + }, + [], + ); + const handleFileDrop = useCallback( (files: FileWithPath[]) => { const droppedFile = files[0]; @@ -39,6 +125,7 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat setFile(droppedFile); setStep("preview"); setImportError(null); + setRestoreStatus("restoring"); void analyzeFile(droppedFile); }, [analyzeFile], @@ -48,59 +135,70 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat setFile(null); setStep("upload"); setImportError(null); - apiDoneRef.current = false; - animDoneRef.current = false; - apiErrorRef.current = null; + setRestoreStatus("restoring"); + restartControllerRef.current?.abort(); + restartControllerRef.current = null; reset(); }, [reset]); - const tryFinalize = useCallback(() => { - if (!apiDoneRef.current) return; + const startReadinessCheck = useCallback((restartAfterMs: number) => { + restartControllerRef.current?.abort(); + const restartController = new AbortController(); + restartControllerRef.current = restartController; + setRestoreStatus("restarting"); - if (apiErrorRef.current) { - setImportError(apiErrorRef.current); - setStep("error"); - return; - } - - if (!animDoneRef.current) return; - onRestoreComplete?.(); - window.location.reload(); - }, [onRestoreComplete]); + void waitForServerReadinessAsync({ restartAfterMs, signal: restartController.signal }).then((result) => { + if (restartControllerRef.current !== restartController || result === "aborted") return; + if (result === "ready") { + window.location.reload(); + return; + } + restartControllerRef.current = null; + setRestoreStatus("timedOut"); + }); + }, []); const handleConfirm = useCallback(async () => { if (!file) return; setStep("restoring"); setImportError(null); - apiDoneRef.current = false; - animDoneRef.current = false; - apiErrorRef.current = null; + setRestoreStatus("restoring"); + restartControllerRef.current?.abort(); + restartControllerRef.current = null; + let restartAfterMs = DEFAULT_RESTART_DELAY_MS; try { const formData = new FormData(); formData.append("file", file); const response = await fetch("/api/backup/import", { method: "POST", body: formData }); + const data = (await response.json().catch(() => null)) as { + error?: unknown; + restartAfterMs?: unknown; + } | null; if (!response.ok) { - try { - const data = await response.json(); - apiErrorRef.current = data.error ?? t("failed.title"); - } catch { - apiErrorRef.current = `Server returned ${response.status}`; - } + const message = typeof data?.error === "string" ? data.error : `Server returned ${response.status}`; + setImportError(message); + setStep("error"); + return; + } + + if ( + typeof data?.restartAfterMs === "number" && + Number.isFinite(data.restartAfterMs) && + data.restartAfterMs >= 0 + ) { + restartAfterMs = data.restartAfterMs; } } catch { - apiErrorRef.current = t("failed.title"); - } finally { - apiDoneRef.current = true; - tryFinalize(); + setImportError(t("failed.title")); + setStep("error"); + return; } - }, [file, t, tryFinalize]); - const handleAnimationComplete = useCallback(() => { - animDoneRef.current = true; - tryFinalize(); - }, [tryFinalize]); + onRestoreComplete?.(); + startReadinessCheck(restartAfterMs); + }, [file, onRestoreComplete, startReadinessCheck, t]); if (step === "upload") { return ( @@ -206,8 +304,9 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat return ( startReadinessCheck(0)} + onReload={() => window.location.reload()} /> ); } diff --git a/apps/nextjs/src/components/backup/restore-progress-panel.spec.tsx b/apps/nextjs/src/components/backup/restore-progress-panel.spec.tsx new file mode 100644 index 000000000..7cbfe0c16 --- /dev/null +++ b/apps/nextjs/src/components/backup/restore-progress-panel.spec.tsx @@ -0,0 +1,99 @@ +// @vitest-environment jsdom + +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { MantineProvider } from "@mantine/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@homarr/translation/client", () => { + const translations: Record = { + "progress.title": "Restoring database...", + "progress.restarting": "Restarting server", + "timeout.title": "Server did not come back online", + "timeout.message": "Retry the connection check or refresh this page manually.", + tryAgain: "Try again", + "docker.action.refresh.label": "Refresh", + }; + return { + useScopedI18n: () => (key: string) => translations[key] ?? key, + useI18n: () => (key: string) => translations[key] ?? key, + }; +}); + +import { RestoreProgressPanel } from "./restore-progress-panel"; + +describe("RestoreProgressPanel", () => { + let host: HTMLDivElement; + let root: ReturnType; + + beforeEach(() => { + vi.stubGlobal( + "matchMedia", + vi.fn().mockImplementation((query: string) => ({ + matches: query.includes("prefers-reduced-motion"), + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); + }); + + const renderPanel = async ( + status: "restoring" | "restarting" | "timedOut", + onRetry = vi.fn(), + onReload = vi.fn(), + ) => { + await act(async () => + root.render( + createElement( + MantineProvider, + null, + createElement(RestoreProgressPanel, { active: true, status, onRetry, onReload }), + ), + ), + ); + return { onRetry, onReload }; + }; + + it("announces real restore and restart state without a motion-only cue", async () => { + await renderPanel("restoring"); + const output = host.querySelector("output"); + expect(output?.getAttribute("aria-live")).toBe("polite"); + expect(output?.getAttribute("aria-busy")).toBe("true"); + expect(output?.textContent).toContain("Restoring database..."); + expect(output?.querySelector("svg")).not.toBeNull(); + + await renderPanel("restarting"); + expect(output?.getAttribute("aria-busy")).toBe("true"); + expect(output?.textContent).toContain("Restarting server"); + }); + + it("exposes keyboard-native retry and manual refresh actions after timeout", async () => { + const { onRetry, onReload } = await renderPanel("timedOut"); + const output = host.querySelector("output"); + const buttons = Array.from(host.querySelectorAll("button")); + + expect(output?.getAttribute("aria-busy")).toBe("false"); + expect(output?.textContent).toContain("Server did not come back online"); + expect(output?.textContent).toContain("Retry the connection check or refresh this page manually."); + expect(buttons.map((button) => button.textContent)).toEqual(["Try again", "Refresh"]); + + buttons[0]?.click(); + buttons[1]?.click(); + expect(onRetry).toHaveBeenCalledOnce(); + expect(onReload).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/nextjs/src/components/backup/restore-progress-panel.tsx b/apps/nextjs/src/components/backup/restore-progress-panel.tsx index 63af14a75..cf3e5d645 100644 --- a/apps/nextjs/src/components/backup/restore-progress-panel.tsx +++ b/apps/nextjs/src/components/backup/restore-progress-panel.tsx @@ -1,164 +1,78 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; -import { Box, Group, Loader, Paper, Stack, Text, ThemeIcon, Timeline } from "@mantine/core"; -import { - IconCheck, - IconDatabaseImport, - IconFileZip, - IconKey, - IconLoader, - IconRefresh, - IconTransform, -} from "@tabler/icons-react"; -import type { Icon } from "@tabler/icons-react"; +import { Button, Group, Loader, Paper, Stack, Text, ThemeIcon } from "@mantine/core"; +import { useReducedMotion } from "@mantine/hooks"; +import { IconAlertTriangle, IconCheck, IconDatabaseImport, IconRefresh } from "@tabler/icons-react"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; -import type { MigrationFile } from "./types"; - -const BASE_PHASE_DURATIONS = { - extracting: 1200, - encrypting: 1500, - swapping: 800, - restarting: 10000, -} as const; - -const MIGRATION_STEP_DURATION = 100; - -interface TimelineStep { - key: string; - label: string; - icon: Icon; - duration: number; -} +export type RestoreProgressStatus = "restoring" | "restarting" | "timedOut"; interface RestoreProgressPanelProps { active: boolean; - migrations: MigrationFile[]; - onComplete?: () => void; + status: RestoreProgressStatus; + onRetry: () => void; + onReload: () => void; } -export const RestoreProgressPanel = ({ active, migrations, onComplete }: RestoreProgressPanelProps) => { - const t = useScopedI18n("management.page.tool.backup.restore.progress"); - const [currentStepIdx, setCurrentStepIdx] = useState(-1); +export const RestoreProgressPanel = ({ active, status, onRetry, onReload }: RestoreProgressPanelProps) => { + const t = useScopedI18n("management.page.tool.backup.restore"); + const tRoot = useI18n(); + const reduceMotion = useReducedMotion(); - const steps = useMemo((): TimelineStep[] => { - const result: TimelineStep[] = [ - { key: "extracting", label: t("extracting"), icon: IconFileZip, duration: BASE_PHASE_DURATIONS.extracting }, - ]; + if (!active) return null; - if (migrations.length > 0) { - for (const migration of migrations) { - result.push({ - key: `migration-${migration.idx}`, - label: migration.tag, - icon: IconDatabaseImport, - duration: MIGRATION_STEP_DURATION, - }); - } - } else { - result.push({ - key: "migrating", - label: t("migrating"), - icon: IconDatabaseImport, - duration: 300, - }); - } - - result.push( - { key: "encrypting", label: t("encrypting"), icon: IconKey, duration: BASE_PHASE_DURATIONS.encrypting }, - { key: "swapping", label: t("swapping"), icon: IconTransform, duration: BASE_PHASE_DURATIONS.swapping }, - { key: "restarting", label: t("restarting"), icon: IconRefresh, duration: BASE_PHASE_DURATIONS.restarting }, - ); - - return result; - }, [migrations, t]); - - useEffect(() => { - if (!active) return; - - let cancelled = false; - const run = async () => { - for (const [i, step] of steps.entries()) { - if (cancelled) return; - setCurrentStepIdx(i); - await new Promise((r) => setTimeout(r, step.duration)); - } - if (!cancelled) { - onComplete?.(); - } - }; - - void run(); - return () => { - cancelled = true; - }; - }, [active, onComplete, steps]); - - if (!active && currentStepIdx === -1) return null; + const isRestoring = status === "restoring"; + const isTimedOut = status === "timedOut"; + const statusLabel = isRestoring ? t("progress.title") : isTimedOut ? t("timeout.message") : t("progress.restarting"); return ( - + - - - - {t("title")} - + + {isTimedOut ? ( + + ) : !isRestoring ? ( + + ) : reduceMotion ? ( + + ) : ( + - - - {steps.map((step, i) => { - const isDone = i < currentStepIdx; - const isActive = i === currentStepIdx; - const isMigration = step.key.startsWith("migration-"); - - return ( - - - - ) : isActive ? ( - - - - ) : ( - - - - ) - } - > - - - - {step.label} - - - - ); - })} - - + {isTimedOut ? ( + + + + + ) : null} ); diff --git a/e2e/manage-collections.spec.ts b/e2e/manage-collections.spec.ts new file mode 100644 index 000000000..78aee5ebe --- /dev/null +++ b/e2e/manage-collections.spec.ts @@ -0,0 +1,142 @@ +import { mkdir } from "node:fs/promises"; +import path from "node:path"; + +import { chromium } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import { stringify as stringifySuperJSON } from "superjson"; +import { describe, expect, test } from "vitest"; + +import * as sqliteSchema from "../packages/db/schema/sqlite"; +import { createHomarrContainer } from "./shared/create-homarr-container"; +import { createSqliteDbFileAsync } from "./shared/e2e-db"; +import { loginAsync } from "./shared/login"; +import { seedAdminUserAsync } from "./shared/seed-admin-user"; + +const credentials = { username: "admin", password: "Comp(exP4sswOrd" }; + +const captureScreenshotAsync = async (page: Page, name: string) => { + const directory = process.env.HOMARR_E2E_SCREENSHOT_DIR?.trim(); + if (!directory) return; + await mkdir(directory, { recursive: true }); + await page.screenshot({ path: path.join(directory, `manage-${name}.png`), fullPage: true }); +}; + +describe("Management collections", () => { + test("renders unified collections and a compact board preview on desktop and mobile", async () => { + const { db, localMountPath } = await createSqliteDbFileAsync(); + const { userId } = await seedAdminUserAsync(db, credentials); + const boardId = "manage-board"; + const layoutId = "manage-layout"; + const sectionId = "manage-section"; + const appId = "manage-app"; + + await db.insert(sqliteSchema.apps).values({ + id: appId, + name: "Very long service name used to verify responsive management rows", + description: "A useful service with enough detail to exercise the shared collection description slot.", + iconUrl: "/favicon.ico", + href: "https://service.example.internal/a/very/long/reverse/proxy/path/that/must/not/overflow", + pingUrl: "https://service.example.internal/health", + }); + await db.insert(sqliteSchema.integrations).values({ + id: "manage-integration", + name: "Living room Sonarr", + url: "https://sonarr.example.internal/a/long/reverse/proxy/path", + kind: "sonarr", + appId, + }); + await db.insert(sqliteSchema.searchEngines).values({ + id: "manage-search-engine", + iconUrl: "/favicon.ico", + name: "Internal documentation", + short: "docs", + description: "Search private documentation from Spotlight.", + urlTemplate: "https://docs.example.internal/search?q=%s", + type: "generic", + }); + await db.insert(sqliteSchema.boards).values({ + id: boardId, + name: "management-preview", + creatorId: userId, + isPublic: false, + }); + await db.insert(sqliteSchema.layouts).values({ + id: layoutId, + name: "Base", + boardId, + columnCount: 10, + breakpoint: 768, + role: "base", + }); + await db.insert(sqliteSchema.sections).values({ + id: sectionId, + boardId, + kind: "empty", + xOffset: 0, + yOffset: 0, + }); + await db.insert(sqliteSchema.items).values([ + { + id: "manage-clock", + boardId, + kind: "clock", + options: stringifySuperJSON({ is24HourFormat: true }), + }, + { + id: "manage-app-item", + boardId, + kind: "app", + options: stringifySuperJSON({ appId, openInNewTab: true, showTitle: true }), + }, + ]); + await db.insert(sqliteSchema.itemLayouts).values([ + { itemId: "manage-clock", sectionId, layoutId, xOffset: 0, yOffset: 0, width: 2, height: 2 }, + { itemId: "manage-app-item", sectionId, layoutId, xOffset: 2, yOffset: 0, width: 1, height: 1 }, + ]); + + const homarrContainer = await createHomarrContainer({ + environment: { AUTH_PROVIDERS: "credentials" }, + mounts: { "/appdata": localMountPath }, + }).start(); + const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; + const browser = await chromium.launch(); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, reducedMotion: "reduce" }); + const page = await context.newPage(); + + try { + await loginAsync({ page, baseUrl, credentials, destination: "/manage/apps" }); + await page.getByRole("heading", { name: "Apps", exact: true }).waitFor(); + expect(await page.getByRole("list", { name: "Configured apps" }).getByRole("listitem").count()).toBe(1); + const editApp = page.getByRole("link", { name: /Edit Very long service name/ }); + await editApp.focus(); + expect(await editApp.evaluate((element) => element === document.activeElement)).toBe(true); + expect((await editApp.boundingBox())?.height).toBeGreaterThanOrEqual(44); + + await page.goto(`${baseUrl}/manage/integrations`); + await page.getByRole("heading", { name: "Integrations", exact: true }).waitFor(); + expect(await page.getByRole("list", { name: "Configured integrations" }).getByRole("listitem").count()).toBe(1); + expect(await page.getByText("Sonarr", { exact: true }).isVisible()).toBe(true); + + await page.goto(`${baseUrl}/manage/search-engines`); + await page.getByRole("heading", { name: "Search engines", exact: true }).waitFor(); + expect(await page.getByRole("list", { name: "Configured search engines" }).getByRole("listitem").count()).toBe(1); + + await page.goto(`${baseUrl}/manage/boards`); + await page.getByRole("heading", { name: "Your boards", exact: true }).waitFor(); + const preview = page.getByRole("img", { name: "Layout preview for management-preview, with 2 items" }); + await preview.waitFor(); + expect(await preview.locator('[data-lane="main"]').count()).toBe(1); + await captureScreenshotAsync(page, "desktop-boards"); + + await page.setViewportSize({ width: 375, height: 812 }); + for (const route of ["apps", "integrations", "search-engines", "boards"]) { + await page.goto(`${baseUrl}/manage/${route}`); + await page.locator("h1").waitFor(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true); + } + await captureScreenshotAsync(page, "mobile-boards"); + } finally { + await Promise.all([browser.close(), homarrContainer.stop()]); + } + }, 90_000); +}); diff --git a/e2e/onboarding.spec.ts b/e2e/onboarding.spec.ts index 5a7a69cf9..f74c60d07 100644 --- a/e2e/onboarding.spec.ts +++ b/e2e/onboarding.spec.ts @@ -1,14 +1,20 @@ -import { mkdir } from "node:fs/promises"; +import { mkdir, readFile } from "node:fs/promises"; import path from "node:path"; import { chromium } from "@playwright/test"; import type { Page } from "@playwright/test"; +import { createId } from "@paralleldrive/cuid2"; +import AdmZip from "adm-zip"; +import type { StartedTestContainer } from "testcontainers"; import { describe, expect, test } from "vitest"; +import * as sqliteSchema from "../packages/db/schema/sqlite"; import { OnboardingActions } from "./shared/actions/onboarding-actions"; import { OnboardingAssertions } from "./shared/assertions/onboarding-assertions"; import { createHomarrContainer } from "./shared/create-homarr-container"; import { createSqliteDbFileAsync } from "./shared/e2e-db"; +import { loginAsync } from "./shared/login"; +import { seedAdminUserAsync } from "./shared/seed-admin-user"; const captureScreenshotAsync = async (page: Page, name: string) => { const directory = process.env.HOMARR_E2E_SCREENSHOT_DIR?.trim(); @@ -17,85 +23,197 @@ 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(); + + 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(); +}; + describe("Onboarding", () => { test.each([ { name: "mobile", width: 320, height: 740 }, { name: "tablet", width: 768, height: 1024 }, { name: "desktop", width: 1440, height: 900 }, - ])("Onboarding is usable at the $name viewport", async ({ name, width, height }) => { + ])( + "Onboarding is usable at the $name viewport", + async ({ name, width, height }) => { + const { localMountPath } = await createSqliteDbFileAsync(); + const homarrContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start(); + const browser = await chromium.launch(); + const context = await browser.newContext({ viewport: { width, height }, reducedMotion: "reduce" }); + const page = await context.newPage(); + + try { + await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/init`); + await page.getByRole("heading", { name: "Make home feel organized." }).waitFor(); + const start = page.getByRole("button", { name: "Get started" }); + await start.focus(); + await start.press("Enter"); + await page.getByRole("heading", { name: "Create your administrator" }).waitFor(); + + await page.getByLabel("Administrator username").fill("admin"); + await page.getByLabel("Password", { exact: true }).fill("Comp(exP4sswOrd"); + await page.getByLabel("Confirm password").fill("Comp(exP4sswOrd"); + await page.locator("css=button[type='submit']").click(); + await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor({ timeout: 30_000 }); + + const serverOrigin = page.getByLabel("Domain, IP, or reverse-proxy path"); + await serverOrigin.fill("https://home.example/homarr"); + await page.getByLabel("Default language").click(); + await page.getByRole("option", { name: /Français/ }).click(); + expect(await serverOrigin.inputValue()).toBe("https://home.example/homarr"); + expect(await page.getByRole("heading", { name: "Start with familiar defaults" }).isVisible()).toBe(true); + + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true); + await captureScreenshotAsync(page, `${name}-studio`); + + const activeSection = page.locator('[aria-current="step"]'); + await activeSection.focus(); + await activeSection.press("ArrowLeft"); + await page.getByRole("heading", { name: "One board, built around your services" }).waitFor(); + expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true); + await activeSection.press("ArrowRight"); + await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor(); + expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true); + await activeSection.press("End"); + await page.getByRole("heading", { name: "One board, built around your services" }).waitFor(); + await activeSection.press("Home"); + await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor(); + expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true); + + const sections = [ + ["Discover", "See what this installation can actually reach"], + ["Connect", "Connect what makes the board useful"], + ["Board", "Shape the board before it is built"], + ["Extend", "Add intelligence when it helps"], + ] as const; + for (const [section, heading] of sections) { + await page.getByRole("button", { name: new RegExp(`^${section}`) }).click(); + await page.getByRole("heading", { name: heading }).waitFor(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)).toBe(true); + } + + await page.getByRole("button", { name: /^Board/ }).click(); + const boardPreview = page.getByLabel("Live preview of the first board layout"); + expect(await boardPreview.getAttribute("data-layout-role")).toBe("base"); + expect(await boardPreview.getAttribute("data-layout-columns")).toBe("10"); + await page.getByRole("radio", { name: "Mobile", exact: true }).click(); + expect(await boardPreview.getAttribute("data-layout-role")).toBe("mobile"); + expect(await boardPreview.getAttribute("data-layout-columns")).toBe("3"); + await page.getByRole("radio", { name: "Wide", exact: true }).click(); + await page.getByRole("radio", { name: "Base", exact: true }).click(); + expect(await boardPreview.getAttribute("data-layout-columns")).toBe("12"); + + await page.getByRole("button", { name: /^Connect/ }).click(); + const sonarr = page.locator('button[aria-label="Sonarr"]'); + await sonarr.click(); + await page.getByLabel("Service URL").waitFor(); + await captureScreenshotAsync(page, `${name}-integration-details`); + await sonarr.click(); + await page.getByLabel("Service URL").waitFor({ state: "detached" }); + + await activeSection.focus(); + await activeSection.press("End"); + await page.getByRole("heading", { name: "One board, built around your services" }).waitFor(); + await captureScreenshotAsync(page, `${name}-review`); + + await page.getByRole("button", { name: "Build my board" }).click(); + await page.getByRole("heading", { name: "Your first board is ready." }).waitFor({ timeout: 30_000 }); + await captureScreenshotAsync(page, `${name}-finish`); + + await page.getByRole("link", { name: "Open my board" }).click(); + await page.waitForURL(/\/boards\/dashboard(?:[/?#]|$)/); + const boardCanvas = page.locator('[data-testid="board-canvas"][data-board-hydrated="true"]'); + await boardCanvas.waitFor({ timeout: 30_000 }); + 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()]); + } + }, + 90_000, + ); + + test("SQLite onboarding restores a validated backup and survives the restart", async () => { + const restoreArchive = await createRestoreArchiveAsync(); + const invalidArchive = new AdmZip(); + invalidArchive.addFile("db.sqlite", Buffer.from("not-a-database")); const { localMountPath } = await createSqliteDbFileAsync(); - const homarrContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start(); + const initialContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start(); + let initialContainerStopped = false; + let restoredContainer: StartedTestContainer | undefined; const browser = await chromium.launch(); - const context = await browser.newContext({ viewport: { width, height }, reducedMotion: "reduce" }); + const context = await browser.newContext(); const page = await context.newPage(); try { - await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}/init`); - await page.getByRole("heading", { name: "Make home feel organized." }).waitFor(); - const start = page.getByRole("button", { name: "Get started" }); - await start.focus(); - await start.press("Enter"); - await page.getByRole("heading", { name: "Create your administrator" }).waitFor(); + const initialBaseUrl = `http://${initialContainer.getHost()}:${initialContainer.getMappedPort(7575)}`; + await page.goto(`${initialBaseUrl}/init`); + await page.getByRole("button", { name: "Come from a backup" }).click(); + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles({ + name: "missing-metadata.zip", + mimeType: "application/zip", + buffer: invalidArchive.toBuffer(), + }); + await page.getByText("Invalid backup: missing metadata.json").waitFor(); + await page.getByRole("button", { name: "Change file" }).click(); - await page.getByLabel("Administrator username").fill("admin"); - await page.getByLabel("Password", { exact: true }).fill("Comp(exP4sswOrd"); - await page.getByLabel("Confirm password").fill("Comp(exP4sswOrd"); - await page.locator("css=button[type='submit']").click(); - await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor({ timeout: 30_000 }); + await fileInput.setInputFiles({ name: "homarr-backup.zip", mimeType: "application/zip", buffer: restoreArchive }); + await page.getByRole("heading", { name: "Backup Contents" }).waitFor({ timeout: 30_000 }); + expect(await page.getByText("restored", { exact: true }).isVisible()).toBe(true); + await page.getByRole("button", { name: "Continue to restore" }).click(); + await page.getByPlaceholder("I understand").fill("I understand"); + const restoreResponse = page.waitForResponse( + (response) => response.url().endsWith("/api/backup/import") && response.request().method() === "POST", + ); + await page.getByRole("button", { name: "Restore Database" }).click(); + expect((await restoreResponse).status()).toBe(200); - expect( - await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), - ).toBe(true); - await captureScreenshotAsync(page, `${name}-studio`); - - const activeSection = page.locator('[aria-current="step"]'); - await activeSection.focus(); - await activeSection.press("ArrowLeft"); - await page.getByRole("heading", { name: "One board, built around your services" }).waitFor(); - expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true); - await activeSection.press("ArrowRight"); - await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor(); - expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true); - await activeSection.press("End"); - await page.getByRole("heading", { name: "One board, built around your services" }).waitFor(); - await activeSection.press("Home"); - await page.getByRole("heading", { name: "Start with familiar defaults" }).waitFor(); - expect(await activeSection.evaluate((element) => element === document.activeElement)).toBe(true); - - const sections = [ - ["Discover", "See what this installation can actually reach"], - ["Connect", "Connect what makes the board useful"], - ["Board", "Shape the board before it is built"], - ["Extend", "Add intelligence when it helps"], - ] as const; - for (const [section, heading] of sections) { - await page.getByRole("button", { name: new RegExp(`^${section}`) }).click(); - await page.getByRole("heading", { name: heading }).waitFor(); - expect( - await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1), - ).toBe(true); - } - - await page.getByRole("button", { name: /^Connect/ }).click(); - const sonarr = page.locator('button[aria-label="Sonarr"]'); - await sonarr.click(); - await page.getByLabel("Service URL").waitFor(); - await captureScreenshotAsync(page, `${name}-integration-details`); - await sonarr.click(); - await page.getByLabel("Service URL").waitFor({ state: "detached" }); - - await activeSection.focus(); - await activeSection.press("End"); - await page.getByRole("heading", { name: "One board, built around your services" }).waitFor(); - await captureScreenshotAsync(page, `${name}-review`); - - await page.getByRole("button", { name: "Build my board" }).click(); - await page.getByRole("heading", { name: "Your first board is ready." }).waitFor({ timeout: 30_000 }); - await captureScreenshotAsync(page, `${name}-finish`); + await initialContainer.stop(); + initialContainerStopped = true; + restoredContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start(); + const restoredBaseUrl = `http://${restoredContainer.getHost()}:${restoredContainer.getMappedPort(7575)}`; + await page.goto(`${restoredBaseUrl}/boards/restored`); + await page.locator('[data-testid="board-canvas"][data-board-hydrated="true"]').waitFor({ timeout: 30_000 }); + expect(page.url()).toMatch(/\/boards\/restored(?:[/?#]|$)/); } finally { - await Promise.all([browser.close(), homarrContainer.stop()]); + await browser.close(); + if (!initialContainerStopped) await initialContainer.stop(); + if (restoredContainer) await restoredContainer.stop(); } - }, 90_000); + }, 120_000); test("Credentials onboarding should be successful", async () => { // Arrange @@ -141,6 +259,37 @@ describe("Onboarding", () => { } }, 60_000); + test("Credentials onboarding recovers when automatic sign-in fails after account creation", async () => { + const { db, localMountPath } = await createSqliteDbFileAsync(); + const homarrContainer = await createHomarrContainer({ mounts: { "/appdata": localMountPath } }).start(); + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; + await page.goto(`${baseUrl}/init`); + await page.getByRole("button", { name: "Get started" }).click(); + await page.getByRole("heading", { name: "Create your administrator" }).waitFor(); + await page.route("**/api/auth/callback/credentials**", async (route) => { + await route.fulfill({ status: 500, contentType: "application/json", body: '{"error":"forced-e2e-failure"}' }); + }); + await page.getByLabel("Administrator username").fill("admin"); + await page.getByLabel("Password", { exact: true }).fill("Comp(exP4sswOrd"); + await page.getByLabel("Confirm password").fill("Comp(exP4sswOrd"); + await page.locator("button[type='submit']").click(); + + await page.getByRole("heading", { name: "Administrator account created" }).waitFor(); + const recovery = page.getByRole("link", { name: "Sign in and continue" }); + expect(await recovery.getAttribute("href")).toBe("/auth/login?callbackUrl=/init"); + expect(await recovery.evaluate((element) => element === document.activeElement)).toBe(true); + 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()]); + } + }, 60_000); + test("External provider onboarding requires sign-in before privileged setup", async () => { // Arrange const { db, localMountPath } = await createSqliteDbFileAsync(); @@ -183,4 +332,94 @@ describe("Onboarding", () => { await Promise.all([browser.close(), homarrContainer.stop()]); } }, 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(); + await seedAdminUserAsync(db, credentials); + await db.update(sqliteSchema.onboarding).set({ step: "import" as never }); + const privateBoardName = "private-board-must-not-leak"; + await db.insert(sqliteSchema.boards).values({ id: createId(), name: privateBoardName, isPublic: false }); + + const privateWorkshopApiUrl = "http://internal-workshop-api.invalid"; + const privateWorkshopWebUrl = "http://internal-workshop-web.invalid"; + const homarrContainer = await createHomarrContainer({ + environment: { + WORKSHOP_API_URL: privateWorkshopApiUrl, + WORKSHOP_WEB_URL: privateWorkshopWebUrl, + }, + mounts: { "/appdata": localMountPath }, + }).start(); + const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; + const browser = await chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + const response = await context.request.get(`${baseUrl}/init`); + expect(response.ok()).toBe(true); + expect(response.url()).toBe(`${baseUrl}/init`); + const responseBody = await response.text(); + expect(responseBody).not.toContain(privateBoardName); + expect(responseBody).not.toContain(privateWorkshopApiUrl); + expect(responseBody).not.toContain(privateWorkshopWebUrl); + + await page.goto(`${baseUrl}/init`); + await page.getByRole("heading", { name: "Administrator sign-in required" }).waitFor(); + expect(await page.getByRole("link", { name: "Sign in and resume" }).getAttribute("href")).toBe( + "/auth/login?callbackUrl=%2Finit", + ); + + await loginAsync({ page, baseUrl, credentials, destination: "/init" }); + await page.getByRole("heading", { name: "Make home feel organized." }).waitFor(); + await page.getByRole("button", { name: "Get started" }).click(); + 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()]); + } + }, 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(); + await seedAdminUserAsync(db, credentials); + await db.update(sqliteSchema.onboarding).set({ step: "setup", previousStep: "group" }); + await db.insert(sqliteSchema.assistantConfigurations).values({ + id: "default", + enabled: true, + provider: "custom", + baseUrl: "http://assistant.local/v1", + modelDiscoveryPath: null, + modelId: "local-model", + }); + + const configuredWorkshopApiUrl = "http://authenticated-workshop-api.invalid"; + const homarrContainer = await createHomarrContainer({ + environment: { WORKSHOP_API_URL: configuredWorkshopApiUrl }, + mounts: { "/appdata": localMountPath }, + }).start(); + const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; + const browser = await chromium.launch(); + const context = await browser.newContext({ viewport: { width: 1280, height: 900 } }); + const page = await context.newPage(); + + try { + await loginAsync({ page, baseUrl, credentials, destination: "/init" }); + expect(await page.locator('meta[name="homarr-workshop-api-url"]').getAttribute("content")).toBe( + configuredWorkshopApiUrl, + ); + await page.getByRole("button", { name: /^Extend/ }).click(); + await page.getByRole("heading", { name: "Add intelligence when it helps" }).waitFor(); + await page.getByLabel("Provider URL").waitFor(); + + const provider = page.getByRole("combobox", { name: "Provider", exact: true }); + await expect.poll(() => provider.inputValue(), { timeout: 10_000 }).toBe("custom"); + expect(await page.getByLabel("Provider URL").inputValue()).toBe("http://assistant.local/v1"); + 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()]); + } + }, 90_000); }); diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 4afa4c764..e75d88178 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -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>(); +const maxManageOverviewPreviewRows = 12; const serializeBoardItemPlacementAsync = async (boardId: string, operation: () => Promise) => { 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(); + 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, }; diff --git a/packages/api/src/router/group.ts b/packages/api/src/router/group.ts index 249cdf910..5e8f8834b 100644 --- a/packages/api/src/router/group.ts +++ b/packages/api/src/router/group.ts @@ -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(); }); }, }); diff --git a/packages/api/src/router/onboard/capability-probes.spec.ts b/packages/api/src/router/onboard/capability-probes.spec.ts new file mode 100644 index 000000000..cd3d0b86e --- /dev/null +++ b/packages/api/src/router/onboard/capability-probes.spec.ts @@ -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().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().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((_, reject) => { + kubernetesSignal = signal; + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }), + ); + + const result = probeRuntimeCapabilitiesAsync({ + kubernetesEnabled: true, + workshopApiUrl: "https://workshop.example.com", + fetchImpl: vi.fn().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); + }); +}); diff --git a/packages/api/src/router/onboard/capability-probes.ts b/packages/api/src/router/onboard/capability-probes.ts new file mode 100644 index 000000000..e0d6fdcc2 --- /dev/null +++ b/packages/api/src/router/onboard/capability-probes.ts @@ -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 ( + operation: (signal: AbortSignal) => Promise, + timeoutMs = capabilityProbeTimeoutMs, +) => { + const controller = new AbortController(); + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + operation(controller.signal), + new Promise((_, 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 => { + const kubernetesPromise: Promise = 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 = 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 }; +}; diff --git a/packages/api/src/router/onboard/onboard-queries.ts b/packages/api/src/router/onboard/onboard-queries.ts index 8124b7c7e..969ffb528 100644 --- a/packages/api/src/router/onboard/onboard-queries.ts +++ b/packages/api/src/router/onboard/onboard-queries.ts @@ -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 => { + 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"; }; diff --git a/packages/api/src/router/onboard/onboard-router.ts b/packages/api/src/router/onboard/onboard-router.ts index ad002c23f..a470b9081 100644 --- a/packages/api/src/router/onboard/onboard-router.ts +++ b/packages/api/src/router/onboard/onboard-router.ts @@ -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(); - - 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(); }); }, }); diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index e349f16e9..47f15a0f1 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -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]); }); }); diff --git a/packages/api/src/router/test/group-onboarding.spec.ts b/packages/api/src/router/test/group-onboarding.spec.ts new file mode 100644 index 000000000..ec9e96db4 --- /dev/null +++ b/packages/api/src/router/test/group-onboarding.spec.ts @@ -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" }); + }); +}); diff --git a/packages/api/src/router/test/onboard.spec.ts b/packages/api/src/router/test/onboard.spec.ts index a2bb27b90..9c596c076 100644 --- a/packages/api/src/router/test/onboard.spec.ts +++ b/packages/api/src/router/test/onboard.spec.ts @@ -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()), createId: createIdMock, @@ -51,7 +53,7 @@ import { normalizeOnboardingStep } from "../onboard/onboard-queries"; const createCaller = (db: ReturnType, 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((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", () => { diff --git a/packages/definitions/src/integration-url-template.ts b/packages/definitions/src/integration-url-template.ts index 2180c81e1..298a65b15 100644 --- a/packages/definitions/src/integration-url-template.ts +++ b/packages/definitions/src/integration-url-template.ts @@ -4,7 +4,7 @@ import { integrationIconSlugs } from "./docker-integration-match"; import type { IntegrationKind } from "./integration"; import { getIntegrationDefaultPort } from "./integration"; -export type UrlTemplateMode = "hostPort" | "subdomain"; +export type UrlTemplateMode = "hostPort" | "subdomain" | "path"; const getSlugForKind = (kind: IntegrationKind): string => integrationIconSlugs[kind]; @@ -13,7 +13,13 @@ const normalizeBaseHost = (value: string) => { if (!trimmed) return null; try { const parsed = new URL(trimmed.includes("://") ? trimmed : `http://${trimmed}`); - return { hostname: parsed.hostname, port: parsed.port ? Number(parsed.port) : undefined }; + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + return { + protocol: parsed.protocol, + hostname: parsed.hostname, + port: parsed.port ? Number(parsed.port) : undefined, + pathname: parsed.pathname.replace(/\/+$/, ""), + }; } catch { return null; } @@ -26,6 +32,8 @@ 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}`, }; return modeBuilders[mode](); }; diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 85fad61a2..b8e0375e4 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -1,4 +1,4 @@ -import { objectKeys, removeTrailingSlash } from "@homarr/common"; +import { objectKeys } from "@homarr/common"; import type { AtLeastOneOf } from "@homarr/common/types"; import { createDocumentationLink } from "./docs"; @@ -546,9 +546,19 @@ export const getIntegrationDocumentationUrl = (kind: IntegrationKind): string | export const getIntegrationApiKeyUrl = (integrationUrl: string, kind: IntegrationKind): string | null => { const definition = integrationDefs[kind]; if (!("apiKeySettingsPath" in definition)) return null; - const base = removeTrailingSlash(integrationUrl); - if (!base) return null; - return `${base}${definition.apiKeySettingsPath}`; + try { + const value = integrationUrl.trim(); + if (!value) return null; + const base = new URL(/^[a-z][a-z0-9+.-]*:\/\//iu.test(value) ? value : `http://${value}`); + if (base.protocol !== "http:" && base.protocol !== "https:") return null; + const settings = new URL(definition.apiKeySettingsPath, "http://localhost"); + base.pathname = `${base.pathname.replace(/\/+$/, "")}/${settings.pathname.replace(/^\/+/, "")}`; + base.search = settings.search; + base.hash = settings.hash; + return base.toString(); + } catch { + return null; + } }; /** diff --git a/packages/definitions/src/test/api-key-url.spec.ts b/packages/definitions/src/test/api-key-url.spec.ts index d9436ab59..ed176be66 100644 --- a/packages/definitions/src/test/api-key-url.spec.ts +++ b/packages/definitions/src/test/api-key-url.spec.ts @@ -51,8 +51,30 @@ describe("getIntegrationApiKeyUrl", () => { }, ); - it("returns null for empty URL", () => { - expect(getIntegrationApiKeyUrl("", "sonarr")).toBeNull(); + it.each(["", "javascript:alert(1)", "data:text/html,test", "file:///etc/passwd"])( + "returns null for an empty or unsafe URL: %s", + (url) => { + expect(getIntegrationApiKeyUrl(url, "sonarr")).toBeNull(); + }, + ); + + it.each([ + ["sonarr.local:8989", "http://sonarr.local:8989/settings/general"], + ["0.0.0.0:8989", "http://0.0.0.0:8989/settings/general"], + ])("normalizes a self-hosted address without a scheme: %s", (url, expected) => { + expect(getIntegrationApiKeyUrl(url, "sonarr")).toBe(expected); + }); + + it("joins onto a reverse-proxy pathname without retaining stale query or fragment state", () => { + expect(getIntegrationApiKeyUrl("https://home.example.com/services/sonarr/?from=old#section", "sonarr")).toBe( + "https://home.example.com/services/sonarr/settings/general", + ); + }); + + it("preserves configured credential fragments while joining pathnames", () => { + expect(getIntegrationApiKeyUrl("https://home.example.com/services/jellyfin/", "jellyfin")).toBe( + "https://home.example.com/services/jellyfin/web/index.html#!/dashboard/keys", + ); }); it("strips trailing slash from base URL", () => { diff --git a/packages/definitions/src/test/integration-url-template.spec.ts b/packages/definitions/src/test/integration-url-template.spec.ts index bebbd37ef..cd84552cb 100644 --- a/packages/definitions/src/test/integration-url-template.spec.ts +++ b/packages/definitions/src/test/integration-url-template.spec.ts @@ -34,6 +34,15 @@ describe("buildIntegrationUrl", () => { ); }); + it("builds reverse-proxy path URLs and preserves the configured base path", () => { + 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", + ); + }); + it("replaces a supplied base port instead of producing a double port", () => { expect(buildIntegrationUrl("sonarr", "https://home.example.com:8443/homarr", "hostPort")).toBe( "http://home.example.com:8989", diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index 25aa3c68c..ea4a71a1a 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -2,6 +2,7 @@ import { createDocumentationLink } from "./docs"; import type { IntegrationKind } from "./integration"; import { getIntegrationKindsByCategory } from "./integration"; import type { WidgetKind } from "./widget"; +import { widgetDefaultSizes } from "./widget"; /** * Static mapping of widget kinds to the integration kinds they support. @@ -121,6 +122,11 @@ export const defaultWidgetConfigs: DefaultWidgetConfig[] = [ { kind: "bookmarks", width: 2, height: 2, options: { title: "Useful Links", layout: "grid", openNewTab: true } }, ]; +const defaultWidgetConfigByKind = new Map(defaultWidgetConfigs.map((config) => [config.kind, config])); + +export const getDefaultWidgetConfig = (kind: WidgetKind): DefaultWidgetConfig => + defaultWidgetConfigByKind.get(kind) ?? { kind, ...(widgetDefaultSizes[kind] ?? { width: 1, height: 1 }) }; + export const generalWidgets: WidgetKind[] = defaultWidgetConfigs .filter((config) => !config.skip) .map((config) => config.kind); diff --git a/packages/docker/src/discovery/list-discovered-containers.spec.ts b/packages/docker/src/discovery/list-discovered-containers.spec.ts index c7ad6b864..a1b1c08e7 100644 --- a/packages/docker/src/discovery/list-discovered-containers.spec.ts +++ b/packages/docker/src/discovery/list-discovered-containers.spec.ts @@ -55,6 +55,7 @@ describe("listDiscoveredContainersAsync", () => { it("times out a blackholed host without losing another host's result", async () => { vi.useFakeTimers(); try { + let blackholedSignal: AbortSignal | undefined; listContainersA.mockResolvedValue([ { Id: "sonarr", @@ -65,7 +66,10 @@ describe("listDiscoveredContainersAsync", () => { }, }, ]); - listContainersB.mockReturnValue(new Promise(() => undefined)); + listContainersB.mockImplementation(({ abortSignal }: { abortSignal: AbortSignal }) => { + blackholedSignal = abortSignal; + return new Promise(() => undefined); + }); const resultPromise = listDiscoveredContainersAsync({}, 25); await vi.advanceTimersByTimeAsync(25); @@ -76,6 +80,8 @@ describe("listDiscoveredContainersAsync", () => { expect.objectContaining({ host: "good", status: "success" }), expect.objectContaining({ host: "bad", status: "unavailable", reason: "Docker discovery timed out for bad" }), ]); + expect(blackholedSignal?.aborted).toBe(true); + expect(listContainersB).toHaveBeenCalledWith({ all: false, abortSignal: blackholedSignal }); } finally { vi.useRealTimers(); } diff --git a/packages/docker/src/discovery/list-discovered-containers.ts b/packages/docker/src/discovery/list-discovered-containers.ts index 8e5aaeed7..e483c778e 100644 --- a/packages/docker/src/discovery/list-discovered-containers.ts +++ b/packages/docker/src/discovery/list-discovered-containers.ts @@ -9,20 +9,6 @@ import type { DockerDiscoveryHostResult, DockerDiscoveryResult } from "./types"; const logger = createLogger({ module: "dockerDiscovery" }); export const dockerDiscoveryHostTimeoutMs = 5_000; -const withTimeoutAsync = async (promise: Promise, timeoutMs: number, host: string) => { - let timeout: ReturnType | undefined; - try { - return await Promise.race([ - promise, - new Promise((_, reject) => { - timeout = setTimeout(() => reject(new Error(`Docker discovery timed out for ${host}`)), timeoutMs); - }), - ]); - } finally { - if (timeout) clearTimeout(timeout); - } -}; - const reasonFromError = (error: unknown) => { if (error instanceof Error && error.message.trim()) return error.message; return "Docker is not reachable"; @@ -35,11 +21,29 @@ export const listDiscoveredContainersAsync = async ( const dockerInstances = DockerSingleton.getInstances(); const settled = await Promise.allSettled( dockerInstances.map(async ({ instance, host }): Promise => { - const containers = await withTimeoutAsync(instance.listContainers({ all: false }), timeoutMs, host); - const services = containers - .map((container) => parseContainerLabels(container, host, options)) - .filter((service) => service !== null); - return { host, status: "success", containers, services }; + const controller = new AbortController(); + const timeoutError = new Error(`Docker discovery timed out for ${host}`); + let timeout: ReturnType | undefined; + try { + const containers = await Promise.race([ + instance.listContainers({ all: false, abortSignal: controller.signal }), + new Promise((_, reject) => { + timeout = setTimeout(() => { + controller.abort(timeoutError); + reject(timeoutError); + }, timeoutMs); + }), + ]); + const services = containers + .map((container) => parseContainerLabels(container, host, options)) + .filter((service) => service !== null); + return { host, status: "success", containers, services }; + } catch (error) { + if (controller.signal.aborted) throw timeoutError; + throw error; + } finally { + if (timeout) clearTimeout(timeout); + } }), ); diff --git a/packages/onboarding/src/account-recovery.spec.ts b/packages/onboarding/src/account-recovery.spec.ts new file mode 100644 index 000000000..dec75539b --- /dev/null +++ b/packages/onboarding/src/account-recovery.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { didCredentialsSignInFail } from "./account-recovery"; + +describe("didCredentialsSignInFail", () => { + it.each([ + [undefined, true], + [{ ok: false }, true], + [{ ok: true, error: "CredentialsSignin" }, true], + [{ ok: true, error: undefined }, false], + ] as const)("resolves %o to %s", (result, expected) => { + expect(didCredentialsSignInFail(result)).toBe(expected); + }); +}); diff --git a/packages/onboarding/src/account-recovery.ts b/packages/onboarding/src/account-recovery.ts new file mode 100644 index 000000000..3808b2c63 --- /dev/null +++ b/packages/onboarding/src/account-recovery.ts @@ -0,0 +1,7 @@ +interface CredentialsSignInResult { + ok: boolean; + error?: string | null; +} + +export const didCredentialsSignInFail = (result: CredentialsSignInResult | undefined) => + result === undefined || !result.ok || Boolean(result.error); diff --git a/packages/onboarding/src/account.tsx b/packages/onboarding/src/account.tsx index 77554503d..2fdce8be3 100644 --- a/packages/onboarding/src/account.tsx +++ b/packages/onboarding/src/account.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useRef, useState } from "react"; import { Alert, Button, Group, Paper, Stack, Text, TextInput, ThemeIcon, Title } from "@mantine/core"; import { IconArrowRight, IconShieldCheck, IconUserPlus } from "@tabler/icons-react"; import type { z } from "zod/v4"; @@ -10,9 +11,11 @@ import { revalidatePathActionAsync } from "@homarr/common/client"; import { useZodForm } from "@homarr/form"; import { UserCreatePasswordFields } from "@homarr/forms-collection"; import { useScopedI18n } from "@homarr/translation/client"; +import { Link } from "@homarr/ui"; import { groupCreateSchema } from "@homarr/validation/group"; import { userInitSchema } from "@homarr/validation/user"; +import { didCredentialsSignInFail } from "./account-recovery"; import type { OnboardingStudioProps } from "./types"; import classes from "./onboarding-studio.module.css"; @@ -56,6 +59,8 @@ export const AccountSetup = ({ environment }: OnboardingStudioProps) => { const CredentialsSetup = () => { const t = useScopedI18n("init.studio.account"); + const [requiresSignIn, setRequiresSignIn] = useState(false); + const recoveryActionRef = useRef(null); const mutation = clientApi.user.initUser.useMutation(); const form = useZodForm(userInitSchema, { initialValues: { username: "", password: "", confirmPassword: "" }, @@ -64,18 +69,47 @@ const CredentialsSetup = () => { const submitAsync = async (values: z.infer) => { try { await mutation.mutateAsync(values); + } catch (error) { + form.setErrors({ username: error instanceof Error ? error.message : t("unknownError") }); + return; + } + + try { const result = await signIn("credentials", { name: values.username, password: values.password, redirect: false, }); - if (result?.error) throw new Error(t("signInError")); - await revalidatePathActionAsync("/init"); - } catch (error) { - form.setErrors({ username: error instanceof Error ? error.message : t("unknownError") }); + if (didCredentialsSignInFail(result)) throw new Error(); + } catch { + setRequiresSignIn(true); + await revalidatePathActionAsync("/init").catch(() => undefined); + return; } + + await revalidatePathActionAsync("/init"); }; + useEffect(() => { + if (requiresSignIn) recoveryActionRef.current?.focus(); + }, [requiresSignIn]); + + if (requiresSignIn) { + return ( + + + + ); + } + return (
void submitAsync(values))}> diff --git a/packages/onboarding/src/claim-state.spec.ts b/packages/onboarding/src/claim-state.spec.ts new file mode 100644 index 000000000..b416e7e1c --- /dev/null +++ b/packages/onboarding/src/claim-state.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { getOnboardingAccessState } from "./claim-state"; + +describe("getOnboardingAccessState", () => { + it("requires existing installations to sign in even when a legacy step normalizes to start", () => { + expect( + getOnboardingAccessState({ + currentStep: "start", + canConfigurePrivileged: false, + hasUsers: true, + externalAuthEnabled: false, + }), + ).toBe("signIn"); + }); + + it.each([ + [{ currentStep: "start", canConfigurePrivileged: false, hasUsers: false, externalAuthEnabled: false }, "ready"], + [{ currentStep: "user", canConfigurePrivileged: false, hasUsers: false, externalAuthEnabled: false }, "claim"], + [{ currentStep: "setup", canConfigurePrivileged: false, hasUsers: false, externalAuthEnabled: true }, "signIn"], + [{ currentStep: "setup", canConfigurePrivileged: true, hasUsers: true, externalAuthEnabled: false }, "ready"], + [{ currentStep: "finish", canConfigurePrivileged: false, hasUsers: true, externalAuthEnabled: false }, "ready"], + ] as const)("resolves %o to %s", (environment, expected) => { + expect(getOnboardingAccessState(environment)).toBe(expected); + }); +}); diff --git a/packages/onboarding/src/claim-state.ts b/packages/onboarding/src/claim-state.ts new file mode 100644 index 000000000..371132a9d --- /dev/null +++ b/packages/onboarding/src/claim-state.ts @@ -0,0 +1,14 @@ +import type { OnboardingEnvironment } from "./types"; + +export type OnboardingClaimState = "checking" | "ready" | "locked" | "signIn" | "error"; + +export const getOnboardingAccessState = ( + environment: Pick< + OnboardingEnvironment, + "currentStep" | "canConfigurePrivileged" | "hasUsers" | "externalAuthEnabled" + >, +): "ready" | "signIn" | "claim" => { + if (environment.currentStep === "finish" || environment.canConfigurePrivileged) return "ready"; + if (environment.hasUsers || (environment.externalAuthEnabled && environment.currentStep === "setup")) return "signIn"; + return environment.currentStep === "start" ? "ready" : "claim"; +}; diff --git a/packages/onboarding/src/discovery-selection.spec.ts b/packages/onboarding/src/discovery-selection.spec.ts new file mode 100644 index 000000000..2e0cae5af --- /dev/null +++ b/packages/onboarding/src/discovery-selection.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { isHttpUrl, resolveDiscoveredAppUrl, takeNewSourceIds } from "./discovery-selection"; + +describe("takeNewSourceIds", () => { + it("keeps same-kind service instances independent and does not reselect them after refresh", () => { + const seen = new Set(); + const sonarrInstances = ["docker:host:sonarr-hd", "docker:host:sonarr-4k"]; + + expect(takeNewSourceIds(sonarrInstances, seen)).toEqual(sonarrInstances); + expect(takeNewSourceIds(sonarrInstances, seen)).toEqual([]); + }); + + it("keeps an explicit app URL and falls back through discovery and server-derived addresses", () => { + expect(resolveDiscoveredAppUrl("https://manual.example", "https://label.example", "http://host:3000")).toBe( + "https://manual.example", + ); + expect(resolveDiscoveredAppUrl(undefined, "https://label.example", "http://host:3000")).toBe( + "https://label.example", + ); + expect(resolveDiscoveredAppUrl(undefined, null, "http://host:3000")).toBe("http://host:3000"); + expect(resolveDiscoveredAppUrl(undefined, null, null)).toBe(""); + }); + + it("accepts only HTTP and HTTPS service addresses", () => { + expect(isHttpUrl("http://home.lan:8989")).toBe(true); + expect(isHttpUrl("https://home.example/sonarr")).toBe(true); + expect(isHttpUrl("home.lan:8989")).toBe(false); + expect(isHttpUrl("javascript:alert(1)")).toBe(false); + }); +}); diff --git a/packages/onboarding/src/discovery-selection.ts b/packages/onboarding/src/discovery-selection.ts new file mode 100644 index 000000000..1d5e1ba3e --- /dev/null +++ b/packages/onboarding/src/discovery-selection.ts @@ -0,0 +1,21 @@ +export const takeNewSourceIds = (sourceIds: string[], seenSourceIds: Set) => + sourceIds.filter((sourceId) => { + if (seenSourceIds.has(sourceId)) return false; + seenSourceIds.add(sourceId); + return true; + }); + +export const resolveDiscoveredAppUrl = ( + override: string | undefined, + suggestedUrl: string | null, + generatedUrl: string | null, +) => override ?? suggestedUrl ?? generatedUrl ?? ""; + +export const isHttpUrl = (value: string) => { + try { + const protocol = new URL(value).protocol; + return protocol === "http:" || protocol === "https:"; + } catch { + return false; + } +}; diff --git a/packages/onboarding/src/finish.tsx b/packages/onboarding/src/finish.tsx index ab327948e..081be8d82 100644 --- a/packages/onboarding/src/finish.tsx +++ b/packages/onboarding/src/finish.tsx @@ -15,9 +15,7 @@ export const Finish = ({ environment }: OnboardingStudioProps) => { const t = useScopedI18n("init.studio.finish"); const reduceMotion = useReducedMotion(); const boardHref = `/boards/${encodeURIComponent(environment.initialBoard?.name ?? "dashboard")}`; - const destination = environment.canConfigurePrivileged - ? boardHref - : `/auth/login?callbackUrl=${encodeURIComponent(boardHref)}`; + const destination = environment.canConfigurePrivileged ? boardHref : "/auth/login?callbackUrl=%2Finit"; return (
diff --git a/packages/onboarding/src/onboarding-studio.module.css b/packages/onboarding/src/onboarding-studio.module.css index d31e49680..77d24eb63 100644 --- a/packages/onboarding/src/onboarding-studio.module.css +++ b/packages/onboarding/src/onboarding-studio.module.css @@ -163,6 +163,22 @@ border: 1px solid color-mix(in srgb, var(--mantine-color-default-border) 76%, transparent); } +.discoveryChoice { + transition: + border-color 150ms ease, + background-color 150ms ease; +} + +.discoveryChoice[data-checked] { + border-color: var(--mantine-primary-color-filled); + background: color-mix(in srgb, var(--mantine-primary-color-light) 62%, var(--mantine-color-body)); +} + +.discoveryChoice:focus-visible { + outline: 3px solid var(--mantine-primary-color-light-color); + outline-offset: 2px; +} + .choiceGrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -208,6 +224,12 @@ border: 1px solid var(--mantine-color-default-border); } +.preview[data-layout-role="mobile"] { + width: min(100%, 28rem); + margin-inline: auto; + aspect-ratio: 4 / 3; +} + .previewLane { min-width: 0; border-radius: calc(var(--mantine-radius-lg) * 0.65); @@ -219,8 +241,8 @@ .previewTiles { height: 100%; display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); grid-auto-rows: minmax(0.8rem, 1fr); + grid-auto-flow: dense; gap: 0.3rem; } @@ -234,23 +256,6 @@ border: 1px solid color-mix(in srgb, var(--mantine-primary-color-filled) 22%, transparent); } -.previewTile:nth-child(1) { - grid-column: span 2; - grid-row: span 2; -} -.previewTile:nth-child(2) { - grid-column: span 2; -} -.previewTile:nth-child(3) { - grid-column: span 1; -} -.previewTile:nth-child(4) { - grid-column: span 1; -} -.previewTile:nth-child(5) { - grid-column: span 4; -} - .assistantStage { position: relative; min-height: 13rem; @@ -490,7 +495,8 @@ .choice, .railButton, - .nextCard { + .nextCard, + .discoveryChoice { transition: none; } diff --git a/packages/onboarding/src/onboarding-studio.tsx b/packages/onboarding/src/onboarding-studio.tsx index a331bbfec..a3fdd8974 100644 --- a/packages/onboarding/src/onboarding-studio.tsx +++ b/packages/onboarding/src/onboarding-studio.tsx @@ -8,6 +8,7 @@ import { Link } from "@homarr/ui"; import type { OnboardingStudioProps } from "./types"; import { AccountSetup } from "./account"; +import { getOnboardingAccessState } from "./claim-state"; import { Finish } from "./finish"; import { SetupStudio } from "./setup-studio"; import { Welcome } from "./welcome"; @@ -15,22 +16,15 @@ import { Welcome } from "./welcome"; export const OnboardingStudio = (props: OnboardingStudioProps) => { const t = useScopedI18n("init.studio.claim"); const tCommon = useScopedI18n("common.action"); + const accessState = getOnboardingAccessState(props.environment); const [claimState, setClaimState] = useState<"checking" | "ready" | "locked" | "signIn" | "error">( - props.environment.currentStep === "start" || props.environment.currentStep === "finish" ? "ready" : "checking", + accessState === "claim" ? "checking" : accessState, ); const [claimError, setClaimError] = useState(null); useEffect(() => { - if (props.environment.currentStep === "start" || props.environment.currentStep === "finish") return; - if (props.environment.canConfigurePrivileged) { - setClaimState("ready"); - return; - } - if ( - props.environment.hasUsers || - (props.environment.externalAuthEnabled && props.environment.currentStep === "setup") - ) { - setClaimState("signIn"); + if (accessState !== "claim") { + setClaimState(accessState); return; } @@ -51,13 +45,7 @@ export const OnboardingStudio = (props: OnboardingStudioProps) => { setClaimState("error"); }); return () => controller.abort(); - }, [ - props.environment.canConfigurePrivileged, - props.environment.currentStep, - props.environment.externalAuthEnabled, - props.environment.hasUsers, - t, - ]); + }, [accessState, t]); if (claimState !== "ready") { const signIn = claimState === "signIn"; diff --git a/packages/onboarding/src/setup-studio.tsx b/packages/onboarding/src/setup-studio.tsx index e8ff32b36..2be212957 100644 --- a/packages/onboarding/src/setup-studio.tsx +++ b/packages/onboarding/src/setup-studio.tsx @@ -71,6 +71,7 @@ import type { IntegrationKind, IntegrationSecretKind, UrlTemplateMode, + WidgetKind, } from "@homarr/definitions"; import { assistantProviderCanUseOpenRouterServerTools, @@ -79,19 +80,23 @@ import { buildAppUrl, buildIntegrationUrl, getAllSecretKindOptions, + getDefaultWidgetConfig, getIntegrationApiKeyUrl, getIntegrationDefaultUrl, getIntegrationDocumentationUrl, getIntegrationName, + getWidgetKindsForIntegration, + generalWidgets, } from "@homarr/definitions"; import { showErrorNotification, showSuccessNotification, showWarningNotification } from "@homarr/notifications"; import type { SupportedLanguage } from "@homarr/translation"; import { localeConfigurations, supportedLanguages } from "@homarr/translation"; -import { useChangeLocale, useCurrentLocale, useScopedI18n } from "@homarr/translation/client"; +import { useCurrentLocale, useScopedI18n } from "@homarr/translation/client"; import { IntegrationAvatar, Link } from "@homarr/ui"; import { IntegrationMultiSelectGrid } from "@homarr/ui/integration-select-grid"; import type { OnboardingStudioProps } from "./types"; +import { isHttpUrl, resolveDiscoveredAppUrl, takeNewSourceIds } from "./discovery-selection"; import classes from "./onboarding-studio.module.css"; type StudioSection = "essentials" | "discover" | "connect" | "board" | "extend" | "review"; @@ -100,19 +105,22 @@ type LayoutPreset = "balanced" | "wide" | "focused"; interface IntegrationDraft { id: string; sourceId?: string; - integrationId?: string; - appId?: string; kind: IntegrationKind; name: string; url: string; source: "manual" | "docker"; secretOption: number; secrets: { kind: IntegrationSecretKind; value: string }[]; - saved: boolean; error: string | null; } type DockerDiscoveryData = RouterOutputs["onboard"]["discoverDockerServices"]; +type RuntimeCapabilitiesQuery = Omit< + ReturnType, + "data" +> & { + data: RouterOutputs["onboard"]["detectRuntimeCapabilities"] | undefined; +}; const sectionDefinitions = [ { id: "essentials", icon: IconAdjustments }, @@ -126,21 +134,22 @@ const sectionDefinitions = [ const radiusValues = ["xs", "sm", "md", "lg", "xl"] as const; const emptyDiscoveredIntegrations: DockerDiscoveryData["integrations"] = []; const emptyDiscoveredApps: DockerDiscoveryData["apps"] = []; - export const SetupStudio = ({ environment }: OnboardingStudioProps) => { const t = useScopedI18n("init.studio"); const currentLocale = useCurrentLocale(); - const { changeLocale, isPending: localePending } = useChangeLocale(); const { colorScheme, setColorScheme } = useMantineColorScheme(); const reduceMotion = useReducedMotion(); const [activeSection, setActiveSection] = useState("essentials"); + const [selectedLocale, setSelectedLocale] = useState(currentLocale); const [serverOrigin, setServerOrigin] = useState(""); const [urlMode, setUrlMode] = useState("hostPort"); const [analyticsEnabled, setAnalyticsEnabled] = useState(true); const [selectedKinds, setSelectedKinds] = useState([]); + const [selectedIntegrationSourceIds, setSelectedIntegrationSourceIds] = useState([]); const [drafts, setDrafts] = useState([]); const [selectedAppIds, setSelectedAppIds] = useState([]); - const [createdAppIdsBySource, setCreatedAppIdsBySource] = useState>({}); + const [appUrlOverrides, setAppUrlOverrides] = useState>({}); + const [appErrorSourceId, setAppErrorSourceId] = useState(null); const [selectedBoardId, setSelectedBoardId] = useState(environment.initialBoard?.id ?? null); const [boardName, setBoardName] = useState(environment.initialBoard?.name ?? "dashboard"); const [primaryColor, setPrimaryColor] = useState("#fa5252"); @@ -156,20 +165,38 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { const sectionHeadingRef = useRef(null); const initialSection = useRef(true); const focusSectionHeading = useRef(true); + const seenIntegrationSourceIds = useRef(new Set()); + const seenAppSourceIds = useRef(new Set()); const docker = clientApi.onboard.discoverDockerServices.useQuery(undefined, { enabled: environment.dockerConfigured, retry: false, refetchOnWindowFocus: false, }); - const createIntegration = clientApi.onboard.createIntegration.useMutation(); - const createApps = clientApi.onboard.createAppsFromDiscovery.useMutation(); + const runtimeCapabilities = clientApi.onboard.detectRuntimeCapabilities.useQuery(undefined, { + retry: false, + refetchOnWindowFocus: false, + }); const complete = clientApi.onboard.completeSetup.useMutation(); - const isApplying = complete.isPending || createIntegration.isPending || createApps.isPending; + const isApplying = complete.isPending; const dockerData = docker.data; const discoveredIntegrations = dockerData?.integrations ?? emptyDiscoveredIntegrations; const discoveredApps = dockerData?.apps ?? emptyDiscoveredApps; + const discoveredAppUrls = useMemo( + () => + Object.fromEntries( + discoveredApps.map((app) => [ + app.sourceId, + resolveDiscoveredAppUrl( + appUrlOverrides[app.sourceId], + app.suggestedUrl, + serverOrigin ? buildAppUrl(app.containerName, serverOrigin, urlMode, app.publishedPort ?? undefined) : null, + ), + ]), + ), + [appUrlOverrides, discoveredApps, serverOrigin, urlMode], + ); const detectedKinds = useMemo( () => new Set(discoveredIntegrations.map((integration) => integration.kind)), [discoveredIntegrations], @@ -177,10 +204,20 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { useEffect(() => { if (!docker.data) return; - setSelectedKinds((current) => [...new Set([...current, ...discoveredIntegrations.map((item) => item.kind)])]); - setSelectedAppIds((current) => - current.length > 0 ? current : discoveredApps.map((application) => application.sourceId), + const newIntegrationSourceIds = takeNewSourceIds( + discoveredIntegrations.map((integration) => integration.sourceId), + seenIntegrationSourceIds.current, ); + if (newIntegrationSourceIds.length > 0) { + setSelectedIntegrationSourceIds((current) => [...new Set([...current, ...newIntegrationSourceIds])]); + } + const newAppSourceIds = takeNewSourceIds( + discoveredApps.map((application) => application.sourceId), + seenAppSourceIds.current, + ); + if (newAppSourceIds.length > 0) { + setSelectedAppIds((current) => [...new Set([...current, ...newAppSourceIds])]); + } }, [docker.data, discoveredApps, discoveredIntegrations]); useEffect(() => { @@ -188,7 +225,7 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { const next: IntegrationDraft[] = []; const existing = new Map(current.map((draft) => [draft.id, draft])); for (const discovered of discoveredIntegrations) { - if (!selectedKinds.includes(discovered.kind)) continue; + if (!selectedIntegrationSourceIds.includes(discovered.sourceId)) continue; const id = discovered.sourceId; const generatedUrl = discovered.suggestedUrl || @@ -212,7 +249,6 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { ); } for (const kind of selectedKinds) { - if (discoveredIntegrations.some((item) => item.kind === kind)) continue; const id = `manual:${kind}`; const generatedUrl = serverOrigin ? buildIntegrationUrl(kind, serverOrigin, urlMode) @@ -235,7 +271,7 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { } return next; }); - }, [discoveredIntegrations, selectedKinds, serverOrigin, urlMode]); + }, [discoveredIntegrations, selectedIntegrationSourceIds, selectedKinds, serverOrigin, urlMode]); useEffect(() => { if (initialSection.current) { @@ -282,9 +318,7 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { }; const updateDraft = (id: string, patch: Partial) => { - setDrafts((current) => - current.map((draft) => (draft.id === id ? { ...draft, ...patch, saved: patch.saved ?? false } : draft)), - ); + setDrafts((current) => current.map((draft) => (draft.id === id ? { ...draft, ...patch } : draft))); }; const selectSecretOption = (draft: IntegrationDraft, option: number) => { @@ -301,6 +335,7 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { const applySetupAsync = async (includeSelections = true) => { setApplyError(null); setAppError(null); + setAppErrorSourceId(null); setApplyProgress(5); try { if (!selectedBoardId && environment.availableBoards.length > 0) { @@ -313,7 +348,7 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { } const draftsToApply = includeSelections ? drafts : []; const invalidDraft = draftsToApply.find( - (draft) => !draft.url.trim() || draft.secrets.some((secret) => secret.value.trim().length === 0), + (draft) => !isHttpUrl(draft.url) || draft.secrets.some((secret) => secret.value.trim().length === 0), ); if (invalidDraft) { const message = t("connect.validationError", { name: invalidDraft.name }); @@ -321,85 +356,24 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { setActiveSection("connect"); throw new Error(message); } - - const unsaved = draftsToApply.filter((draft) => !draft.saved); - const selectedIntegrationIds = draftsToApply.flatMap((draft) => - draft.integrationId ? [draft.integrationId] : [], - ); - const integrationAppIds = draftsToApply.flatMap((draft) => (draft.appId ? [draft.appId] : [])); - for (const [index, draft] of unsaved.entries()) { - setApplyMessage(t("review.progress.integration", { name: draft.name })); - setApplyProgress(10 + Math.round(((index + 1) / Math.max(unsaved.length, 1)) * 45)); - try { - const discovered = draft.sourceId - ? discoveredIntegrations.find((integration) => integration.sourceId === draft.sourceId) - : undefined; - const result = await createIntegration.mutateAsync({ - name: draft.name, - url: draft.url, - kind: draft.kind, - secrets: draft.secrets, - sourceId: draft.sourceId, - iconUrl: discovered?.iconUrl ?? null, - description: discovered?.description ?? null, - pingUrl: discovered?.pingUrl ?? null, - }); - if (result && "error" in result && result.error) { - throw new Error(t("connect.connectionError", { name: draft.name })); - } - selectedIntegrationIds.push(result.id); - integrationAppIds.push(result.appId); - updateDraft(draft.id, { saved: true, error: null, integrationId: result.id, appId: result.appId }); - } catch (error) { - const message = error instanceof Error ? error.message : t("common.unknownError"); - updateDraft(draft.id, { error: message }); - throw error; - } - } - const appsToCreate = includeSelections ? discoveredApps.filter((app) => selectedAppIds.includes(app.sourceId)) : []; - let createdAppIds = appsToCreate.flatMap((app) => { - const appId = createdAppIdsBySource[app.sourceId]; - return appId ? [appId] : []; - }); - if (appsToCreate.length > 0) { - setApplyMessage(t("review.progress.apps")); - setApplyProgress(68); - try { - const result = await createApps.mutateAsync( - appsToCreate.map((app) => ({ - sourceId: app.sourceId, - name: app.containerName, - href: - app.suggestedUrl || - (serverOrigin - ? buildAppUrl(app.containerName, serverOrigin, urlMode, app.publishedPort ?? undefined) - : null), - pingUrl: app.pingUrl ?? null, - iconUrl: app.iconUrl, - description: app.description ?? null, - })), - ); - const createdApps = Object.fromEntries( - result.apps.flatMap((app) => (app.sourceId ? [[app.sourceId, app.id]] : [])), - ); - setCreatedAppIdsBySource((current) => ({ ...current, ...createdApps })); - createdAppIds = result.apps.map((app) => app.id); - } catch (error) { - setAppError(error instanceof Error ? error.message : t("common.unknownError")); - setActiveSection("connect"); - throw error; - } + const appWithoutAddress = appsToCreate.find((app) => !isHttpUrl(discoveredAppUrls[app.sourceId] ?? "")); + if (appWithoutAddress) { + const message = t("connect.appAddressRequired", { name: appWithoutAddress.containerName }); + setAppError(message); + setAppErrorSourceId(appWithoutAddress.sourceId); + setActiveSection("connect"); + throw new Error(message); } - setApplyMessage(t("review.progress.board")); - setApplyProgress(84); + setApplyMessage(t("review.progress.applying")); + setApplyProgress(20); const selectedSourceIds = new Set(draftsToApply.flatMap((draft) => (draft.sourceId ? [draft.sourceId] : []))); const completion = await complete.mutateAsync({ server: { - defaultLocale: currentLocale, + defaultLocale: selectedLocale, defaultColorScheme: colorScheme as ColorScheme, analyticsEnabled, }, @@ -413,8 +387,38 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { leftSidebar, rightSidebar, }, - selectedIntegrationIds, - selectedAppIds: [...new Set([...integrationAppIds, ...createdAppIds])], + integrations: draftsToApply.map((draft) => { + const discovered = draft.sourceId + ? discoveredIntegrations.find((integration) => integration.sourceId === draft.sourceId) + : undefined; + return { + sourceId: draft.sourceId ?? draft.id, + name: draft.name, + url: draft.url, + kind: draft.kind, + secrets: draft.secrets, + iconUrl: discovered?.iconUrl ?? null, + description: discovered?.description ?? null, + pingUrl: discovered?.pingUrl ?? null, + }; + }), + apps: appsToCreate.flatMap((app) => { + const href = discoveredAppUrls[app.sourceId]; + return href + ? [ + { + sourceId: app.sourceId, + name: app.containerName, + href, + pingUrl: app.pingUrl ?? null, + iconUrl: app.iconUrl, + description: app.description ?? null, + }, + ] + : []; + }), + selectedIntegrationIds: [], + selectedAppIds: [], selectedDockerSourceIds: [ ...new Set([ ...discoveredIntegrations @@ -428,9 +432,12 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { selectedWidgetKinds: [ ...new Set( [ + ...generalWidgets, ...discoveredIntegrations.filter((integration) => selectedSourceIds.has(integration.sourceId)), ...appsToCreate, - ].flatMap((discovery) => (discovery.widgetKind ? [discovery.widgetKind] : [])), + ].flatMap((selection) => + typeof selection === "string" ? [selection] : selection.widgetKind ? [selection.widgetKind] : [], + ), ), ], }); @@ -462,20 +469,31 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { setUrlMode, analyticsEnabled, setAnalyticsEnabled, - currentLocale, - changeLocale, - localePending, + selectedLocale, + setSelectedLocale, colorScheme: colorScheme as ColorScheme, setColorScheme, docker, dockerData, + runtimeCapabilities, selectedKinds, setSelectedKinds, + discoveredIntegrations, + selectedIntegrationSourceIds, + setSelectedIntegrationSourceIds, detectedKinds, drafts, updateDraft, selectSecretOption, discoveredApps, + discoveredAppUrls, + setDiscoveredAppUrl: (sourceId, url) => { + setAppUrlOverrides((current) => ({ ...current, [sourceId]: url })); + if (appErrorSourceId === sourceId) { + setAppErrorSourceId(null); + setAppError(null); + } + }, selectedAppIds, setSelectedAppIds, boardName, @@ -498,6 +516,7 @@ export const SetupStudio = ({ environment }: OnboardingStudioProps) => { applyMessage, applyError, appError, + appErrorSourceId, }; return ( @@ -638,20 +657,25 @@ interface StudioSectionProps { setUrlMode: (value: UrlTemplateMode) => void; analyticsEnabled: boolean; setAnalyticsEnabled: (value: boolean) => void; - currentLocale: SupportedLanguage; - changeLocale: (value: SupportedLanguage) => void; - localePending: boolean; + selectedLocale: SupportedLanguage; + setSelectedLocale: (value: SupportedLanguage) => void; colorScheme: ColorScheme; setColorScheme: (value: ColorScheme) => void; docker: ReturnType; dockerData: DockerDiscoveryData | undefined; + runtimeCapabilities: RuntimeCapabilitiesQuery; selectedKinds: IntegrationKind[]; setSelectedKinds: (value: IntegrationKind[]) => void; + discoveredIntegrations: DockerDiscoveryData["integrations"]; + selectedIntegrationSourceIds: string[]; + setSelectedIntegrationSourceIds: (value: string[]) => void; detectedKinds: Set; drafts: IntegrationDraft[]; updateDraft: (id: string, patch: Partial) => void; selectSecretOption: (draft: IntegrationDraft, option: number) => void; discoveredApps: DockerDiscoveryData["apps"]; + discoveredAppUrls: Record; + setDiscoveredAppUrl: (sourceId: string, url: string) => void; selectedAppIds: string[]; setSelectedAppIds: (value: string[]) => void; boardName: string; @@ -674,6 +698,7 @@ interface StudioSectionProps { applyMessage: string; applyError: string | null; appError: string | null; + appErrorSourceId: string | null; } const StudioSectionContent = ({ section, ...props }: StudioSectionProps & { section: StudioSection }) => { @@ -694,20 +719,15 @@ const StudioSectionContent = ({ section, ...props }: StudioSectionProps & { sect }; const SectionHeading = ({ - eyebrow, title, description, headingRef, }: { - eyebrow: string; title: string; description: string; headingRef: React.RefObject; }) => ( - - {eyebrow} - {title} @@ -721,12 +741,7 @@ const Essentials = (props: StudioSectionProps) => { const t = useScopedI18n("init.studio.essentials"); return ( - + ({ value: model.id, label: model.name }))} - value={modelId} - onChange={(value) => setModelId(value ?? "")} - placeholder={models.isFetching ? t("discovering") : t("modelPlaceholder")} - disabled={models.isFetching} - /> + {useManualModelInput ? ( + setModelId(event.currentTarget.value)} + placeholder={t("manualModelPlaceholder")} + description={t("manualModelDescription")} + /> + ) : ( +