fix: harden onboarding completion

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 01:20:27 +02:00
1 parent 576cc0b418
commit 07770e862c
44 files changed
+3003 -1000

No files matched your search

@@ -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
+25 -16
View File
@@ -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,
+6 -2
View File
@@ -173,8 +173,12 @@ export default async function Layout(props: {
>
<head>
<meta name="homarr-website-url" content={publicUrls.homarrWebsiteUrl} />
<meta name="homarr-workshop-api-url" content={publicUrls.workshopApiUrl} />
<meta name="homarr-workshop-web-url" content={publicUrls.workshopWebUrl} />
{session ? (
<>
<meta name="homarr-workshop-api-url" content={publicUrls.workshopApiUrl} />
<meta name="homarr-workshop-web-url" content={publicUrls.workshopWebUrl} />
</>
) : null}
<SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
</head>
@@ -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 | AnyMappedTestConnectionError>(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 && <IntegrationTestConnectionError error={error} url={form.values.url} />}
{!isOnboarding && supportsSearchEngine && (
{supportsSearchEngine && (
<Checkbox
label={t("integration.field.attemptSearchEngineCreation.label")}
description={t("integration.field.attemptSearchEngineCreation.description", {
@@ -216,7 +197,7 @@ export const NewIntegrationForm = ({
/>
)}
{!isOnboarding && <AppForm form={form} canCreateApps={canCreateApps} />}
<AppForm form={form} canCreateApps={canCreateApps} />
<Group justify="end" align="center">
{onCancel ? (
@@ -228,12 +209,7 @@ export const NewIntegrationForm = ({
{t("common.action.backToOverview")}
</Button>
)}
{onSkip && (
<Button variant="default" onClick={onSkip}>
{t("common.action.skip")}
</Button>
)}
<Button type="submit" loading={isPending}>
<Button type="submit" loading={isCreatePending}>
{t("integration.testConnection.action.create")}
</Button>
</Group>
+161 -35
View File
@@ -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<Uint8Array<ArrayBuffer>> => {
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<FormData> => {
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<typeof Database>, 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<typeof Database> | 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);
}
}
}
@@ -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<void>((resolve) => {
signalStarted = resolve;
});
const release = new Promise<void>((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"]);
});
});
@@ -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<boolean>((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();
});
});
@@ -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<boolean>;
}
const waitAsync = (durationMs: number, signal: AbortSignal) =>
new Promise<boolean>((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<ServerReadinessResult> => {
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<FileWithPath | null>(null);
const [step, setStep] = useState<RestoreStep>("upload");
const [importError, setImportError] = useState<string | null>(null);
const apiDoneRef = useRef(false);
const animDoneRef = useRef(false);
const apiErrorRef = useRef<string | null>(null);
const [restoreStatus, setRestoreStatus] = useState<"restoring" | "restarting" | "timedOut">("restoring");
const restartControllerRef = useRef<AbortController | null>(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 (
<RestoreProgressPanel
active
migrations={analysis?.migrations.pending ?? []}
onComplete={handleAnimationComplete}
status={restoreStatus}
onRetry={() => startReadinessCheck(0)}
onReload={() => window.location.reload()}
/>
);
}
@@ -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<string, string> = {
"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<typeof createRoot>;
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();
});
});
@@ -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 (
<Paper p="lg" radius="md" bg="var(--mantine-color-dark-7)">
<Paper
component="output"
p="lg"
radius="md"
bg="var(--mantine-color-dark-7)"
aria-live="polite"
aria-atomic="true"
aria-busy={!isTimedOut}
>
<Stack gap="md">
<Group gap="xs">
<Loader size="sm" color="blue" />
<Text size="sm" fw={600}>
{t("title")}
</Text>
<Group gap="sm">
{isTimedOut ? (
<ThemeIcon size="lg" radius="xl" color="orange" variant="light" aria-hidden="true">
<IconAlertTriangle size={18} />
</ThemeIcon>
) : !isRestoring ? (
<ThemeIcon size="lg" radius="xl" color="green" variant="light" aria-hidden="true">
<IconCheck size={18} />
</ThemeIcon>
) : reduceMotion ? (
<ThemeIcon size="lg" radius="xl" color="blue" variant="light" aria-hidden="true">
<IconDatabaseImport size={18} />
</ThemeIcon>
) : (
<Loader size="sm" color="blue" aria-hidden="true" />
)}
<div>
<Text size="sm" fw={600}>
{isTimedOut ? t("timeout.title") : t("progress.title")}
</Text>
<Text size="xs" c="dimmed">
{statusLabel}
</Text>
</div>
</Group>
<Box pl={4}>
<Timeline active={currentStepIdx} bulletSize={24} lineWidth={2} color="blue">
{steps.map((step, i) => {
const isDone = i < currentStepIdx;
const isActive = i === currentStepIdx;
const isMigration = step.key.startsWith("migration-");
return (
<Timeline.Item
key={step.key}
bullet={
isDone ? (
<ThemeIcon size={24} radius="xl" color="green" variant="filled">
<IconCheck size={14} />
</ThemeIcon>
) : isActive ? (
<ThemeIcon size={24} radius="xl" color="blue" variant="filled">
<Loader size={12} color="white" />
</ThemeIcon>
) : (
<ThemeIcon size={24} radius="xl" color="gray" variant="light">
<IconLoader size={14} />
</ThemeIcon>
)
}
>
<Group gap="xs" mt={-4}>
<step.icon
size={16}
color={
isDone
? "var(--mantine-color-green-6)"
: isActive
? "var(--mantine-color-blue-5)"
: "var(--mantine-color-dimmed)"
}
/>
<Text
size={isMigration ? "xs" : "sm"}
fw={isActive ? 600 : 400}
ff={isMigration ? "monospace" : undefined}
c={isDone ? "green" : isActive ? "white" : "dimmed"}
>
{step.label}
</Text>
</Group>
</Timeline.Item>
);
})}
</Timeline>
</Box>
{isTimedOut ? (
<Group>
<Button variant="light" onClick={onRetry} leftSection={<IconRefresh size={16} />}>
{t("tryAgain")}
</Button>
<Button variant="default" onClick={onReload}>
{tRoot("docker.action.refresh.label")}
</Button>
</Group>
) : null}
</Stack>
</Paper>
);
+142
View File
@@ -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);
});
+305 -66
View File
@@ -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);
});
+108 -37
View File
@@ -8,7 +8,7 @@ import type { GridAlgorithmItem } from "@homarr/common";
import type { DeviceType } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
import { and, asc, eq, handleTransactionsAsync, inArray, isNull, like, not, or, sql } from "@homarr/db";
import { and, asc, eq, gt, gte, handleTransactionsAsync, inArray, isNull, like, lt, not, or, sql } from "@homarr/db";
import { createDbInsertCollectionWithoutTransaction } from "@homarr/db/collection";
import { seedProtectedBoardLayoutsAsync } from "@homarr/db/migrations/seed";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
@@ -84,6 +84,7 @@ interface BoardItemPlacementRectangle {
}
const boardItemPlacementTails = new Map<string, Promise<void>>();
const maxManageOverviewPreviewRows = 12;
const serializeBoardItemPlacementAsync = async <T>(boardId: string, operation: () => Promise<T>) => {
const previous = boardItemPlacementTails.get(boardId) ?? Promise.resolve();
@@ -261,6 +262,11 @@ export const boardRouter = createTRPCRouter({
breakpoint: true,
role: true,
},
orderBy: (layout, { desc, sql }) => [
sql`CASE WHEN ${layout.role} = 'base' THEN 0 ELSE 1 END`,
desc(layout.breakpoint),
],
limit: 1,
},
sections: {
columns: {
@@ -268,26 +274,21 @@ export const boardRouter = createTRPCRouter({
kind: true,
xOffset: true,
},
with: {
layouts: {
columns: {
layoutId: true,
parentSectionId: true,
xOffset: true,
yOffset: true,
width: true,
height: true,
},
},
},
where: eq(sections.kind, "empty"),
orderBy: (section, { asc }) => [asc(section.xOffset), asc(section.id)],
},
items: {
columns: {
id: true,
},
with: {
layouts: {
},
where: getAccessibleBoardsWhere(ctx.session?.user.permissions.includes("board-view-all"), userId, boardIds),
});
const previewLayoutIds = dbBoards.flatMap((board) => board.layouts.map((layout) => layout.id));
const rootSectionIds = dbBoards.flatMap((board) => board.sections.map((section) => section.id));
const [previewItemLayouts, previewSectionLayouts] =
previewLayoutIds.length > 0 && rootSectionIds.length > 0
? await Promise.all([
ctx.db.query.itemLayouts.findMany({
columns: {
itemId: true,
layoutId: true,
sectionId: true,
xOffset: true,
@@ -295,17 +296,93 @@ export const boardRouter = createTRPCRouter({
width: true,
height: true,
},
},
},
},
},
where: getAccessibleBoardsWhere(ctx.session?.user.permissions.includes("board-view-all"), userId, boardIds),
});
where: and(
inArray(itemLayouts.layoutId, previewLayoutIds),
inArray(itemLayouts.sectionId, rootSectionIds),
gte(itemLayouts.xOffset, 0),
gte(itemLayouts.yOffset, 0),
lt(itemLayouts.yOffset, maxManageOverviewPreviewRows),
gt(itemLayouts.width, 0),
gt(itemLayouts.height, 0),
),
}),
ctx.db.query.sectionLayouts.findMany({
columns: {
sectionId: true,
layoutId: true,
parentSectionId: true,
xOffset: true,
yOffset: true,
width: true,
height: true,
},
with: {
section: {
columns: { kind: true },
},
},
where: and(
inArray(sectionLayouts.layoutId, previewLayoutIds),
inArray(sectionLayouts.parentSectionId, rootSectionIds),
gte(sectionLayouts.xOffset, 0),
gte(sectionLayouts.yOffset, 0),
lt(sectionLayouts.yOffset, maxManageOverviewPreviewRows),
gt(sectionLayouts.width, 0),
gt(sectionLayouts.height, 0),
),
}),
])
: [[], []];
return dbBoards.map(({ layouts: boardLayouts, sections: boardSections, items: boardItems, ...board }) => {
const previewLayout =
boardLayouts.find((layout) => layout.role === "base") ??
boardLayouts.toSorted((first, second) => second.breakpoint - first.breakpoint).at(0);
return dbBoards.map(({ layouts: boardLayouts, sections: boardSections, ...board }) => {
const previewLayout = boardLayouts.at(0);
const rootsByLane = new Map<BoardLane, (typeof boardSections)[number]>();
for (const section of boardSections) {
const lane = getRootSectionLane(section.xOffset);
if (!rootsByLane.has(lane)) rootsByLane.set(lane, section);
}
const previewRoots = boardLanes.flatMap((lane) => {
const root = rootsByLane.get(lane);
return root ? [{ id: root.id, kind: "empty" as const, xOffset: root.xOffset, layouts: [] }] : [];
});
const rootColumnCountById = new Map(
boardLanes.flatMap((lane) => {
const root = rootsByLane.get(lane);
return root && previewLayout ? [[root.id, getBoardLaneColumnCount(previewLayout, lane)] as const] : [];
}),
);
const isInsideRootLane = (layout: { sectionId: string; xOffset: number }) => {
const columnCount = rootColumnCountById.get(layout.sectionId);
return columnCount !== undefined && columnCount > 0 && layout.xOffset < columnCount;
};
const isInsideParentRootLane = (layout: { parentSectionId: string | null; xOffset: number }) => {
if (!layout.parentSectionId) return false;
const columnCount = rootColumnCountById.get(layout.parentSectionId);
return columnCount !== undefined && columnCount > 0 && layout.xOffset < columnCount;
};
const itemPreview = previewLayout
? previewItemLayouts
.filter((layout) => layout.layoutId === previewLayout.id && isInsideRootLane(layout))
.map((layout) => ({ id: layout.itemId, layouts: [layout] }))
: [];
const containerPreview = previewLayout
? previewSectionLayouts
.filter(
(layout) =>
layout.layoutId === previewLayout.id &&
layout.section.kind === "container" &&
isInsideParentRootLane(layout),
)
.map((layout) => ({
id: layout.sectionId,
kind: "container" as const,
xOffset: null,
layouts: [layout],
}))
: [];
return {
...board,
@@ -314,14 +391,8 @@ export const boardRouter = createTRPCRouter({
preview: previewLayout
? {
layouts: [previewLayout],
sections: boardSections.map((section) => ({
...section,
layouts: section.layouts.filter((layout) => layout.layoutId === previewLayout.id),
})),
items: boardItems.map((item) => ({
...item,
layouts: item.layouts.filter((layout) => layout.layoutId === previewLayout.id),
})),
sections: [...previewRoots, ...containerPreview],
items: itemPreview,
}
: null,
};
+20 -2
View File
@@ -1,3 +1,5 @@
import type { MySqlRawQueryResult } from "drizzle-orm/mysql2";
import type { QueryResult } from "pg";
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
@@ -193,16 +195,32 @@ export const groupRouter = createTRPCRouter({
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
const transitionResult = (await transaction
.update(schema.onboarding)
.set({ previousStep: "group", step: "setup" })
.where(eq(schema.onboarding.step, "group"))) as MySqlRawQueryResult | QueryResult;
const transitionedRows = Array.isArray(transitionResult)
? transitionResult[0].affectedRows
: (transitionResult.rowCount ?? 0);
if (transitionedRows !== 1) {
throw new TRPCError({ code: "CONFLICT", message: "The initial external group was already created." });
}
await transaction.insert(schema.groups).values(groupRow);
await transaction.insert(schema.groupPermissions).values({ groupId, permission: "admin" });
await transaction.update(schema.onboarding).set({ previousStep: "group", step: "setup" });
});
},
handleSync(db) {
db.transaction((transaction) => {
const transitionResult = transaction
.update(onboarding)
.set({ previousStep: "group", step: "setup" })
.where(eq(onboarding.step, "group"))
.run();
if (transitionResult.changes !== 1) {
throw new TRPCError({ code: "CONFLICT", message: "The initial external group was already created." });
}
transaction.insert(groups).values(groupRow).run();
transaction.insert(groupPermissions).values({ groupId, permission: "admin" }).run();
transaction.update(onboarding).set({ previousStep: "group", step: "setup" }).run();
});
},
});
@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { probeRuntimeCapabilitiesAsync } from "./capability-probes";
describe("probeRuntimeCapabilitiesAsync", () => {
afterEach(() => {
vi.useRealTimers();
});
it("reports independently reachable runtime capabilities", async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response("{}", { status: 200 }));
await expect(
probeRuntimeCapabilitiesAsync({
kubernetesEnabled: true,
workshopApiUrl: "https://workshop.example.com/",
fetchImpl,
getKubernetesVersionAsync: async () => ({ gitVersion: "v1.34.0" }),
}),
).resolves.toEqual({
kubernetes: { status: "available", detail: "v1.34.0" },
workshop: { status: "available" },
});
expect(fetchImpl).toHaveBeenCalledWith(
"https://workshop.example.com/api/health",
expect.objectContaining({ headers: { Accept: "application/json" } }),
);
});
it("distinguishes disabled and unreachable capabilities", async () => {
await expect(
probeRuntimeCapabilitiesAsync({
kubernetesEnabled: false,
workshopApiUrl: "https://workshop.example.com",
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 503 })),
}),
).resolves.toEqual({
kubernetes: { status: "disabled" },
workshop: { status: "unavailable" },
});
});
it("aborts a never-resolving Kubernetes request when the probe times out", async () => {
vi.useFakeTimers();
let kubernetesSignal: AbortSignal | undefined;
const getKubernetesVersionAsync = vi.fn(
async (signal: AbortSignal) =>
await new Promise<never>((_, reject) => {
kubernetesSignal = signal;
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
}),
);
const result = probeRuntimeCapabilitiesAsync({
kubernetesEnabled: true,
workshopApiUrl: "https://workshop.example.com",
fetchImpl: vi.fn<typeof fetch>().mockResolvedValue(new Response(null, { status: 200 })),
getKubernetesVersionAsync,
});
await vi.advanceTimersByTimeAsync(5_000);
await expect(result).resolves.toEqual({
kubernetes: { status: "unavailable" },
workshop: { status: "available" },
});
expect(getKubernetesVersionAsync).toHaveBeenCalledOnce();
expect(kubernetesSignal?.aborted).toBe(true);
});
});
@@ -0,0 +1,80 @@
import type { RequestContext, ResponseContext } from "@kubernetes/client-node";
import { KubernetesClient } from "../kubernetes/kubernetes-client";
export type RuntimeCapability =
| { status: "available"; detail?: string }
| { status: "disabled" }
| { status: "unavailable" };
export interface RuntimeCapabilities {
kubernetes: RuntimeCapability;
workshop: RuntimeCapability;
}
const capabilityProbeTimeoutMs = 5_000;
const withTimeoutAsync = async <T>(
operation: (signal: AbortSignal) => Promise<T>,
timeoutMs = capabilityProbeTimeoutMs,
) => {
const controller = new AbortController();
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
operation(controller.signal),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
const error = new Error("Capability probe timed out");
controller.abort(error);
reject(error);
}, timeoutMs);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
};
export const probeRuntimeCapabilitiesAsync = async ({
kubernetesEnabled,
workshopApiUrl,
fetchImpl = fetch,
getKubernetesVersionAsync = async (signal) =>
await KubernetesClient.getInstance().versionApi.getCode({
middleware: [
{
pre: async (request: RequestContext) => {
request.setSignal(signal);
return request;
},
post: async (response: ResponseContext) => response,
},
],
}),
}: {
kubernetesEnabled: boolean;
workshopApiUrl: string;
fetchImpl?: typeof fetch;
getKubernetesVersionAsync?: (signal: AbortSignal) => Promise<{ gitVersion?: string }>;
}): Promise<RuntimeCapabilities> => {
const kubernetesPromise: Promise<RuntimeCapability> = kubernetesEnabled
? withTimeoutAsync(getKubernetesVersionAsync)
.then((version) => ({ status: "available" as const, detail: version.gitVersion }))
.catch(() => ({ status: "unavailable" as const }))
: Promise.resolve({ status: "disabled" as const });
const workshopPromise: Promise<RuntimeCapability> = withTimeoutAsync(
async (signal) =>
await fetchImpl(`${workshopApiUrl.replace(/\/+$/u, "")}/api/health`, {
headers: { Accept: "application/json" },
signal,
}).then((response) => {
if (!response.ok) throw new Error(`Workshop health returned ${response.status}`);
}),
)
.then(() => ({ status: "available" as const }))
.catch(() => ({ status: "unavailable" as const }));
const [kubernetes, workshop] = await Promise.all([kubernetesPromise, workshopPromise]);
return { kubernetes, workshop };
};
@@ -6,7 +6,7 @@ import type { OnboardingStep } from "@homarr/definitions";
export const nextOnboardingStepAsync = async (db: Database) => {
const { current } = await getOnboardingOrFallbackAsync(db);
if (current !== "start") return;
const nextStep = getNextOnboardingStep();
const nextStep = await getNextOnboardingStepAsync(db);
if (!nextStep) return;
await db.update(onboarding).set({ previousStep: current, step: nextStep });
@@ -22,8 +22,9 @@ export const getOnboardingOrFallbackAsync = async (db: Database) => {
};
};
const getNextOnboardingStep = (): OnboardingStep => {
if (isProviderEnabled("credentials")) return "user";
const getNextOnboardingStepAsync = async (db: Database): Promise<OnboardingStep> => {
const existingUser = await db.query.users.findFirst({ columns: { id: true } });
if (isProviderEnabled("credentials") && !existingUser) return "user";
if (isProviderEnabled("ldap") || isProviderEnabled("oidc")) return "group";
return "setup";
};
+202 -198
View File
@@ -1,5 +1,7 @@
import { createHash } from "node:crypto";
import type { MySqlRawQueryResult } from "drizzle-orm/mysql2";
import type { QueryResult } from "pg";
import { TRPCError } from "@trpc/server";
import SuperJSON from "superjson";
import { z } from "zod/v4";
@@ -25,34 +27,28 @@ import {
serverSettings,
} from "@homarr/db/schema";
import {
defaultWidgetConfigs,
emptySuperJSON,
everyoneGroup,
extractContainerImageName,
getBoardLaneColumnCount,
getDefaultWidgetConfig,
getIconUrl,
getRootSectionLane,
getWidgetIntegrationIssue,
getWidgetKindsForIntegration,
integrationKinds,
integrationSecretKinds,
matchIntegrationKindFromContainer,
rootSectionOffsets,
widgetIntegrationLimits,
} from "@homarr/definitions";
import type { IntegrationKind, OnboardingLayoutPreset, WidgetKind } from "@homarr/definitions";
import {
onboardingCompleteSetupSchema,
onboardingCreateIntegrationSchema,
onboardingDiscoveredAppSchema,
} from "@homarr/validation/onboarding";
import { zodEnumFromArray } from "@homarr/validation/enums";
import { onboardingCompleteSetupSchema } from "@homarr/validation/onboarding";
import { onboardingClaimSettingKey } from "../../onboarding-claim";
import { env } from "../../env";
import { createTRPCRouter, onboardingClaimedProcedure, onboardingProcedure, publicProcedure } from "../../trpc";
import { MissingSecretError, testConnectionAsync } from "../integration/integration-test-connection";
import { mapTestConnectionError } from "../integration/map-test-connection-error";
import { getOnboardingOrFallbackAsync, nextOnboardingStepAsync } from "./onboard-queries";
import { probeRuntimeCapabilitiesAsync } from "./capability-probes";
interface PortInfo {
IP?: string;
@@ -125,8 +121,17 @@ const getLayoutPresetColumnCount = (preset: OnboardingLayoutPreset) => {
};
const normalizeLabelBoardName = (name: string) => name.replace(/[^A-Za-z0-9-_]+/g, "-").replace(/^-+|-+$/g, "");
const defaultAppIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
const defaultWidgetConfigByKind = new Map(defaultWidgetConfigs.map((config) => [config.kind, config]));
const runtimeCapabilitySchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("available"), detail: z.string().optional() }),
z.object({ status: z.literal("disabled") }),
z.object({ status: z.literal("unavailable") }),
]);
const runtimeCapabilitiesSchema = z.object({
kubernetes: runtimeCapabilitySchema,
workshop: runtimeCapabilitySchema,
});
export const onboardRouter = createTRPCRouter({
currentStep: publicProcedure.query(async ({ ctx }) => await getOnboardingOrFallbackAsync(ctx.db)),
@@ -139,102 +144,15 @@ export const onboardRouter = createTRPCRouter({
await nextOnboardingStepAsync(ctx.db);
}),
createIntegration: onboardingProcedure
detectRuntimeCapabilities: onboardingProcedure
.requiresStep("setup")
.input(
onboardingCreateIntegrationSchema.extend({
kind: zodEnumFromArray(integrationKinds),
secrets: z.array(
z.object({
kind: zodEnumFromArray(integrationSecretKinds),
value: z.string().nonempty(),
}),
),
}),
)
.mutation(async ({ ctx, input }) => {
const testResult = await testConnectionAsync({
id: "new",
name: input.name,
url: input.url,
kind: input.kind,
secrets: input.secrets,
}).catch((error) => {
if (!(error instanceof MissingSecretError)) throw error;
throw new TRPCError({ code: "BAD_REQUEST", message: error.message });
.output(runtimeCapabilitiesSchema)
.query(async () => {
const { env: dockerEnv } = await import("@homarr/docker/env");
return await probeRuntimeCapabilitiesAsync({
kubernetesEnabled: dockerEnv.ENABLE_KUBERNETES,
workshopApiUrl: env.WORKSHOP_API_URL ?? env.HOMARR_WEBSITE_URL,
});
if (!testResult.success) return { error: mapTestConnectionError(testResult.error) };
const integrationId = input.sourceId ? stableOnboardingId("integration", input.sourceId) : createId();
const existingIntegration = await ctx.db.query.integrations.findFirst({
where: eq(integrations.id, integrationId),
});
const appId =
existingIntegration?.appId ?? (input.sourceId ? stableOnboardingId("app", input.sourceId) : createId());
const existingApp = await ctx.db.query.apps.findFirst({ where: eq(apps.id, appId) });
const appRow = {
id: appId,
name: input.name,
iconUrl: input.iconUrl ?? getIconUrl(input.kind),
href: input.url,
pingUrl: input.pingUrl ?? input.url,
description: input.description ?? null,
};
const integrationRow = {
id: integrationId,
name: input.name,
url: input.url,
kind: input.kind,
appId,
};
const secretRows = input.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId,
}));
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
if (existingApp) {
await transaction.update(schema.apps).set(appRow).where(eq(schema.apps.id, appId));
} else {
await transaction.insert(schema.apps).values(appRow);
}
if (existingIntegration) {
await transaction
.update(schema.integrations)
.set(integrationRow)
.where(eq(schema.integrations.id, integrationId));
await transaction
.delete(schema.integrationSecrets)
.where(eq(schema.integrationSecrets.integrationId, integrationId));
} else {
await transaction.insert(schema.integrations).values(integrationRow);
}
if (secretRows.length > 0) await transaction.insert(schema.integrationSecrets).values(secretRows);
});
},
handleSync(db) {
db.transaction((transaction) => {
if (existingApp) {
transaction.update(apps).set(appRow).where(eq(apps.id, appId)).run();
} else {
transaction.insert(apps).values(appRow).run();
}
if (existingIntegration) {
transaction.update(integrations).set(integrationRow).where(eq(integrations.id, integrationId)).run();
transaction.delete(integrationSecrets).where(eq(integrationSecrets.integrationId, integrationId)).run();
} else {
transaction.insert(integrations).values(integrationRow).run();
}
if (secretRows.length > 0) transaction.insert(integrationSecrets).values(secretRows).run();
});
},
});
return { id: integrationId, appId };
}),
discoverDockerServices: onboardingProcedure.requiresStep("setup").query(async ({ ctx }) => {
@@ -372,72 +290,6 @@ export const onboardRouter = createTRPCRouter({
}
}),
createAppsFromDiscovery: onboardingProcedure
.requiresStep("setup")
.input(z.array(onboardingDiscoveredAppSchema).max(128))
.mutation(async ({ ctx, input }) => {
const defaultIcon = "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/homarr.svg";
const planned = [] as Array<{
id: string;
sourceId?: string;
existing: boolean;
row: typeof apps.$inferInsert;
}>;
const seenIds = new Set<string>();
for (const app of input) {
const stableId = app.sourceId ? stableOnboardingId("app", app.sourceId) : undefined;
const existing = stableId
? await ctx.db.query.apps.findFirst({ where: eq(apps.id, stableId) })
: app.href
? await ctx.db.query.apps.findFirst({ where: eq(apps.href, app.href) })
: undefined;
const id = existing?.id ?? stableId ?? createId();
if (seenIds.has(id)) continue;
seenIds.add(id);
planned.push({
id,
sourceId: app.sourceId,
existing: Boolean(existing),
row: {
id,
name: app.name,
iconUrl: app.iconUrl ?? defaultIcon,
href: app.href,
pingUrl: app.pingUrl ?? app.href,
description: app.description ?? null,
},
});
}
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
for (const app of planned) {
if (app.existing) {
await transaction.update(schema.apps).set(app.row).where(eq(schema.apps.id, app.id));
} else {
await transaction.insert(schema.apps).values(app.row);
}
}
});
},
handleSync(db) {
db.transaction((transaction) => {
for (const app of planned) {
if (app.existing) {
transaction.update(apps).set(app.row).where(eq(apps.id, app.id)).run();
} else {
transaction.insert(apps).values(app.row).run();
}
}
});
},
});
return { apps: planned.map(({ id, sourceId }) => ({ id, sourceId })) };
}),
completeSetup: onboardingProcedure
.requiresStep("setup")
.input(onboardingCompleteSetupSchema)
@@ -518,20 +370,114 @@ export const onboardRouter = createTRPCRouter({
message: "Select the exact board to configure when more than one board exists.",
});
}
const selectedIntegrations =
await Promise.all(
input.integrations.map(async (integration) => {
const result = await testConnectionAsync({
id: stableOnboardingId("integration", integration.sourceId),
name: integration.name,
url: integration.url,
kind: integration.kind,
secrets: integration.secrets,
}).catch((error) => {
if (!(error instanceof MissingSecretError)) throw error;
throw new TRPCError({ code: "BAD_REQUEST", message: error.message });
});
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Unable to connect to ${integration.name}: ${result.error.message}`,
});
}
}),
);
const plannedIntegrationIds = input.integrations.map((integration) =>
stableOnboardingId("integration", integration.sourceId),
);
const existingPlannedIntegrations =
plannedIntegrationIds.length > 0
? await ctx.db.query.integrations.findMany({ where: inArray(integrations.id, plannedIntegrationIds) })
: [];
const existingIntegrationById = new Map(
existingPlannedIntegrations.map((integration) => [integration.id, integration]),
);
const integrationPlans = input.integrations.map((integration) => {
const id = stableOnboardingId("integration", integration.sourceId);
const existing = existingIntegrationById.get(id);
const appId = existing?.appId ?? stableOnboardingId("app", integration.sourceId);
return {
id,
existing: existing !== undefined,
row: {
id,
name: integration.name,
url: integration.url,
kind: integration.kind,
appId,
} satisfies typeof integrations.$inferSelect,
appRow: {
id: appId,
name: integration.name,
iconUrl: integration.iconUrl ?? getIconUrl(integration.kind),
href: integration.url,
pingUrl: integration.pingUrl ?? integration.url,
description: integration.description ?? null,
} satisfies typeof apps.$inferSelect,
secretRows: integration.secrets.map((secret) => ({
kind: secret.kind,
value: encryptSecret(secret.value),
integrationId: id,
})),
};
});
const plannedAppRows = [
...integrationPlans.map((plan) => plan.appRow),
...input.apps.map(
(app) =>
({
id: stableOnboardingId("app", app.sourceId),
name: app.name,
iconUrl: app.iconUrl ?? defaultAppIcon,
href: app.href,
pingUrl: app.pingUrl ?? app.href,
description: app.description ?? null,
}) satisfies typeof apps.$inferSelect,
),
];
const plannedAppIds = plannedAppRows.map((app) => app.id);
if (new Set(plannedAppIds).size !== plannedAppIds.length) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Onboarding app source IDs resolve to the same app." });
}
const existingPlannedApps =
plannedAppIds.length > 0 ? await ctx.db.query.apps.findMany({ where: inArray(apps.id, plannedAppIds) }) : [];
const existingPlannedAppIds = new Set(existingPlannedApps.map((app) => app.id));
const appPlans = plannedAppRows.map((row) => ({ id: row.id, existing: existingPlannedAppIds.has(row.id), row }));
const plannedIntegrationIdSet = new Set(plannedIntegrationIds);
const plannedAppIdSet = new Set(plannedAppIds);
if (input.selectedIntegrationIds.some((id) => plannedIntegrationIdSet.has(id))) {
throw new TRPCError({ code: "BAD_REQUEST", message: "A new integration cannot also be selected by ID." });
}
if (input.selectedAppIds.some((id) => plannedAppIdSet.has(id))) {
throw new TRPCError({ code: "BAD_REQUEST", message: "A new app cannot also be selected by ID." });
}
const persistedSelectedIntegrations =
input.selectedIntegrationIds.length > 0
? await ctx.db.query.integrations.findMany({ where: inArray(integrations.id, input.selectedIntegrationIds) })
: [];
const selectedApps =
const persistedSelectedApps =
input.selectedAppIds.length > 0
? await ctx.db.query.apps.findMany({ where: inArray(apps.id, input.selectedAppIds) })
: [];
if (selectedIntegrations.length !== input.selectedIntegrationIds.length) {
if (persistedSelectedIntegrations.length !== input.selectedIntegrationIds.length) {
throw new TRPCError({ code: "BAD_REQUEST", message: "One or more selected integrations no longer exist." });
}
if (selectedApps.length !== input.selectedAppIds.length) {
if (persistedSelectedApps.length !== input.selectedAppIds.length) {
throw new TRPCError({ code: "BAD_REQUEST", message: "One or more selected apps no longer exist." });
}
const selectedIntegrations = [...persistedSelectedIntegrations, ...integrationPlans.map((plan) => plan.row)];
const selectedApps = [...persistedSelectedApps, ...plannedAppRows];
const selectedDockerSources =
input.selectedDockerSourceIds.length > 0
@@ -571,7 +517,11 @@ export const onboardRouter = createTRPCRouter({
}
targetBoardName = parsedName.data;
}
if (boardList.some((board) => board.id !== targetBoard.id && board.name === targetBoardName)) {
if (
boardList.some(
(board) => board.id !== targetBoard.id && board.name.toLowerCase() === targetBoardName.toLowerCase(),
)
) {
throw new TRPCError({ code: "CONFLICT", message: "A board with this name already exists." });
}
const targetBoardNames = new Set([targetBoard.name, targetBoardName]);
@@ -770,8 +720,8 @@ export const onboardRouter = createTRPCRouter({
}
}
for (const [kind, integrationIdsForWidget] of selectedIntegrationIdsByWidget) {
const config = defaultWidgetConfigByKind.get(kind);
if (config?.skip) continue;
const config = getDefaultWidgetConfig(kind);
if (config.skip) continue;
const existing = existingItemsByKind.get(kind);
const limit = widgetIntegrationLimits[kind] ?? Number.POSITIVE_INFINITY;
let unlinkedIntegrationIds = integrationIdsForWidget;
@@ -795,9 +745,9 @@ export const onboardRouter = createTRPCRouter({
if (getWidgetIntegrationIssue(kind, integrationKindsForItem)) continue;
const itemId = placeNewItem(
kind,
config?.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
config?.width ?? 2,
config?.height ?? 2,
config.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
config.width,
config.height,
);
newIntegrationItems.push(...integrationIdsForItem.map((integrationId) => ({ itemId, integrationId })));
}
@@ -808,13 +758,13 @@ export const onboardRouter = createTRPCRouter({
for (const kind of input.selectedWidgetKinds.filter((kind) => !selectedLabelWidgetKinds.has(kind))) {
if (existingItemsByKind.has(kind) || newItems.some((item) => item.kind === kind)) continue;
if (getWidgetIntegrationIssue(kind, [])) continue;
const config = defaultWidgetConfigByKind.get(kind);
if (config?.skip) continue;
const config = getDefaultWidgetConfig(kind);
if (config.skip) continue;
placeNewItem(
kind,
config?.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
config?.width ?? 2,
config?.height ?? 2,
config.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
config.width,
config.height,
);
}
const dockerSourceByAppId = new Map(
@@ -863,13 +813,13 @@ export const onboardRouter = createTRPCRouter({
} else if (existing) {
continue;
}
const config = defaultWidgetConfigByKind.get(service.widgetKind);
if (config?.skip) continue;
const config = getDefaultWidgetConfig(service.widgetKind);
if (config.skip) continue;
const itemId = placeNewItem(
service.widgetKind,
config?.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
config?.width ?? 2,
config?.height ?? 2,
config.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
config.width,
config.height,
);
if (integration) newIntegrationItems.push({ itemId, integrationId: integration.id });
}
@@ -942,13 +892,13 @@ export const onboardRouter = createTRPCRouter({
continue;
}
const config = defaultWidgetConfigByKind.get(service.widgetKind);
if (config?.skip) continue;
const config = getDefaultWidgetConfig(service.widgetKind);
if (config.skip) continue;
groupedItemPlans.push({
kind: service.widgetKind,
options: config?.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
width: config?.width ?? 2,
height: config?.height ?? 2,
options: config.options ? SuperJSON.stringify(config.options) : emptySuperJSON,
width: config.width,
height: config.height,
integrationId: integration?.id,
});
}
@@ -1053,6 +1003,36 @@ export const onboardRouter = createTRPCRouter({
await handleTransactionsAsync(ctx.db, {
async handleAsync(db, schema) {
await db.transaction(async (transaction) => {
const transitionResult = (await transaction
.update(schema.onboarding)
.set({ previousStep: "setup", step: "finish" })
.where(eq(schema.onboarding.step, "setup"))) as MySqlRawQueryResult | QueryResult;
const transitionedRows = Array.isArray(transitionResult)
? transitionResult[0].affectedRows
: (transitionResult.rowCount ?? 0);
if (transitionedRows !== 1) {
throw new TRPCError({ code: "CONFLICT", message: "Onboarding setup was already completed." });
}
for (const plan of appPlans) {
if (plan.existing) {
await transaction.update(schema.apps).set(plan.row).where(eq(schema.apps.id, plan.id));
} else {
await transaction.insert(schema.apps).values(plan.row);
}
}
for (const plan of integrationPlans) {
if (plan.existing) {
await transaction.update(schema.integrations).set(plan.row).where(eq(schema.integrations.id, plan.id));
await transaction
.delete(schema.integrationSecrets)
.where(eq(schema.integrationSecrets.integrationId, plan.id));
} else {
await transaction.insert(schema.integrations).values(plan.row);
}
if (plan.secretRows.length > 0) {
await transaction.insert(schema.integrationSecrets).values(plan.secretRows);
}
}
const boardValues = {
name: targetBoardName,
primaryColor: input.board.primaryColor,
@@ -1113,11 +1093,36 @@ export const onboardRouter = createTRPCRouter({
await transaction
.delete(schema.serverSettings)
.where(eq(schema.serverSettings.settingKey, onboardingClaimSettingKey));
await transaction.update(schema.onboarding).set({ previousStep: "setup", step: "finish" });
});
},
handleSync(db) {
db.transaction((transaction) => {
const transitionResult = transaction
.update(onboarding)
.set({ previousStep: "setup", step: "finish" })
.where(eq(onboarding.step, "setup"))
.run();
if (transitionResult.changes !== 1) {
throw new TRPCError({ code: "CONFLICT", message: "Onboarding setup was already completed." });
}
for (const plan of appPlans) {
if (plan.existing) {
transaction.update(apps).set(plan.row).where(eq(apps.id, plan.id)).run();
} else {
transaction.insert(apps).values(plan.row).run();
}
}
for (const plan of integrationPlans) {
if (plan.existing) {
transaction.update(integrations).set(plan.row).where(eq(integrations.id, plan.id)).run();
transaction.delete(integrationSecrets).where(eq(integrationSecrets.integrationId, plan.id)).run();
} else {
transaction.insert(integrations).values(plan.row).run();
}
if (plan.secretRows.length > 0) {
transaction.insert(integrationSecrets).values(plan.secretRows).run();
}
}
const boardValues = {
name: targetBoardName,
primaryColor: input.board.primaryColor,
@@ -1176,7 +1181,6 @@ export const onboardRouter = createTRPCRouter({
.where(eq(groups.name, everyoneGroup))
.run();
transaction.delete(serverSettings).where(eq(serverSettings.settingKey, onboardingClaimSettingKey)).run();
transaction.update(onboarding).set({ previousStep: "setup", step: "finish" }).run();
});
},
});
+151 -15
View File
@@ -330,7 +330,11 @@ describe("getManageOverview", () => {
const baseLayoutId = createId();
const mobileLayoutId = createId();
const rootSectionId = createId();
const containerSectionId = createId();
const itemId = createId();
const mobileItemId = createId();
const offscreenItemId = createId();
const nestedItemId = createId();
await db.insert(users).values({ id: defaultCreatorId });
await db.insert(boards).values([
@@ -356,23 +360,75 @@ describe("getManageOverview", () => {
role: "base",
},
]);
await db.insert(sections).values({
id: rootSectionId,
boardId,
kind: "empty",
xOffset: 0,
yOffset: 0,
});
await db.insert(items).values({ id: itemId, boardId, kind: "clock" });
await db.insert(itemLayouts).values({
itemId,
sectionId: rootSectionId,
await db.insert(sections).values([
{
id: rootSectionId,
boardId,
kind: "empty",
xOffset: 0,
yOffset: 0,
},
{
id: containerSectionId,
boardId,
kind: "container",
xOffset: null,
yOffset: null,
},
]);
await db.insert(sectionLayouts).values({
sectionId: containerSectionId,
parentSectionId: rootSectionId,
layoutId: baseLayoutId,
xOffset: 2,
yOffset: 1,
width: 4,
height: 2,
xOffset: 0,
yOffset: 4,
width: 6,
height: 3,
});
await db.insert(items).values([
{ id: itemId, boardId, kind: "clock" },
{ id: mobileItemId, boardId, kind: "clock" },
{ id: offscreenItemId, boardId, kind: "clock" },
{ id: nestedItemId, boardId, kind: "clock" },
]);
await db.insert(itemLayouts).values([
{
itemId,
sectionId: rootSectionId,
layoutId: baseLayoutId,
xOffset: 2,
yOffset: 1,
width: 4,
height: 2,
},
{
itemId: mobileItemId,
sectionId: rootSectionId,
layoutId: mobileLayoutId,
xOffset: 0,
yOffset: 0,
width: 3,
height: 1,
},
{
itemId: offscreenItemId,
sectionId: rootSectionId,
layoutId: baseLayoutId,
xOffset: 0,
yOffset: 12,
width: 2,
height: 1,
},
{
itemId: nestedItemId,
sectionId: containerSectionId,
layoutId: baseLayoutId,
xOffset: 0,
yOffset: 0,
width: 2,
height: 1,
},
]);
const result = await caller.getManageOverview();
@@ -388,9 +444,89 @@ describe("getManageOverview", () => {
layouts: [{ layoutId: baseLayoutId, sectionId: rootSectionId, width: 4, height: 2 }],
},
],
sections: [
{ id: rootSectionId, kind: "empty" },
{
id: containerSectionId,
kind: "container",
layouts: [{ layoutId: baseLayoutId, parentSectionId: rootSectionId, yOffset: 4 }],
},
],
},
});
expect(result[0]?.preview?.layouts).toHaveLength(1);
expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([itemId]);
expect(result[0]?.preview?.sections).toHaveLength(2);
});
test("uses the highest-breakpoint layout when a board has no base layout", async () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const boardId = createId();
const mobileLayoutId = createId();
const tabletLayoutId = createId();
const rootSectionId = createId();
const mobileItemId = createId();
const tabletItemId = createId();
await db.insert(users).values({ id: defaultCreatorId });
await db.insert(boards).values({ id: boardId, name: "fallback-overview", creatorId: defaultCreatorId });
await db.insert(layouts).values([
{
id: mobileLayoutId,
name: "Mobile",
boardId,
columnCount: 3,
breakpoint: 0,
role: "mobile",
},
{
id: tabletLayoutId,
name: "Tablet",
boardId,
columnCount: 8,
breakpoint: 600,
role: "custom",
},
]);
await db.insert(sections).values({
id: rootSectionId,
boardId,
kind: "empty",
xOffset: 0,
yOffset: 0,
});
await db.insert(items).values([
{ id: mobileItemId, boardId, kind: "clock" },
{ id: tabletItemId, boardId, kind: "clock" },
]);
await db.insert(itemLayouts).values([
{
itemId: mobileItemId,
sectionId: rootSectionId,
layoutId: mobileLayoutId,
xOffset: 0,
yOffset: 0,
width: 3,
height: 1,
},
{
itemId: tabletItemId,
sectionId: rootSectionId,
layoutId: tabletLayoutId,
xOffset: 0,
yOffset: 0,
width: 4,
height: 1,
},
]);
const result = await caller.getManageOverview();
expect(result[0]?.preview?.layouts).toEqual([
expect.objectContaining({ id: tabletLayoutId, breakpoint: 600, role: "custom" }),
]);
expect(result[0]?.preview?.items.map((item) => item.id)).toEqual([tabletItemId]);
});
});
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import type { Session } from "@homarr/auth";
import { groups, groupPermissions, onboarding } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { groupRouter } from "../group";
const adminSession = {
user: { id: "admin", permissions: ["admin"], colorScheme: "light" },
expires: new Date().toISOString(),
} satisfies Session;
describe("group.createInitialExternalGroup", () => {
it("commits exactly one concurrent group-to-setup transition", async () => {
const db = createDb();
await db.insert(onboarding).values({ id: "onboarding", step: "group", previousStep: "start" });
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
const results = await Promise.allSettled([
caller.createInitialExternalGroup({ name: "LDAP admins" }),
caller.createInitialExternalGroup({ name: "OIDC admins" }),
]);
expect(results.map((result) => result.status).toSorted()).toEqual(["fulfilled", "rejected"]);
expect(results.find((result) => result.status === "rejected")).toMatchObject({
reason: expect.objectContaining({ message: "The initial external group was already created." }),
});
const createdGroups = await db.select().from(groups);
expect(createdGroups).toHaveLength(1);
expect(["LDAP admins", "OIDC admins"]).toContain(createdGroups[0]?.name);
expect(await db.select().from(groupPermissions)).toEqual([
expect.objectContaining({ groupId: createdGroups[0]?.id, permission: "admin" }),
]);
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "group" });
});
});
+171 -36
View File
@@ -20,12 +20,14 @@ import {
} from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { WidgetKind } from "@homarr/definitions";
import type { OnboardingCompleteSetupInput } from "@homarr/validation/onboarding";
import { claimOnboardingAsync, isOnboardingClaimValidAsync } from "../../onboarding-claim";
import * as integrationConnection from "../integration/integration-test-connection";
const listDiscoveredContainersAsync = vi.hoisted(() => vi.fn());
const createIdMock = vi.hoisted(() => vi.fn());
const isProviderEnabled = vi.hoisted(() => vi.fn<(provider: string) => boolean>(() => false));
const stableAppIdForSource = (sourceId: string) => {
const digest = createHash("sha256").update(sourceId).digest("hex").slice(0, 32);
return `onboarding_app_${digest}`;
@@ -35,7 +37,7 @@ const stableIntegrationIdForSource = (sourceId: string) => {
return `onboarding_integration_${digest}`;
};
vi.mock("@homarr/auth/server", () => ({ isProviderEnabled: () => false }));
vi.mock("@homarr/auth/server", () => ({ isProviderEnabled }));
vi.mock("@homarr/common", async (importOriginal) => ({
...(await importOriginal<typeof import("@homarr/common")>()),
createId: createIdMock,
@@ -51,7 +53,7 @@ import { normalizeOnboardingStep } from "../onboard/onboard-queries";
const createCaller = (db: ReturnType<typeof createDb>, onboardingClaimToken?: string) =>
onboardRouter.createCaller({ db, deviceType: undefined, session: null, onboardingClaimToken });
const completionInput = (boardId: string, name = "Configured-board") => ({
const completionInput = (boardId: string, name = "Configured-board"): OnboardingCompleteSetupInput => ({
server: {
defaultLocale: "en" as const,
defaultColorScheme: "dark" as const,
@@ -69,6 +71,8 @@ const completionInput = (boardId: string, name = "Configured-board") => ({
},
selectedIntegrationIds: [] as string[],
selectedAppIds: [] as string[],
integrations: [],
apps: [],
selectedDockerSourceIds: [] as string[],
selectedWidgetKinds: [] as WidgetKind[],
});
@@ -188,6 +192,63 @@ describe("onboard.completeSetup", () => {
expect(await isOnboardingClaimValidAsync(db, claimToken)).toBe(false);
});
it("rejects a stale completion after another submission claims the setup transition", async () => {
const db = createDb();
const claimToken = await seedOnboardingAsync(db);
await seedBoardAsync(db, { boardId: "target" });
const input = completionInput("target");
input.selectedDockerSourceIds = ["docker:missing"];
input.selectedWidgetKinds = ["clock"];
let releaseDiscovery: () => void = () => undefined;
const discoveryGate = new Promise<void>((resolve) => {
releaseDiscovery = resolve;
});
listDiscoveredContainersAsync.mockImplementation(async () => {
await discoveryGate;
return { hosts: [], services: [] };
});
const submission = Promise.allSettled([createCaller(db, claimToken).completeSetup(input)]);
try {
await vi.waitFor(() => expect(listDiscoveredContainersAsync).toHaveBeenCalledOnce());
await db.update(onboarding).set({ previousStep: "setup", step: "finish" });
} finally {
releaseDiscovery();
}
const [result] = await submission;
expect(result).toMatchObject({
status: "rejected",
reason: expect.objectContaining({ message: "Onboarding setup was already completed." }),
});
expect(await db.query.items.findMany({ where: eq(items.kind, "clock") })).toHaveLength(0);
expect(await db.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({ name: "target" });
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "finish", previousStep: "setup" });
});
it("uses canonical widget sizes for automatically generated integration widgets", async () => {
const db = createDb();
const claimToken = await seedOnboardingAsync(db);
await seedBoardAsync(db, { boardId: "target" });
await db.insert(integrations).values({
id: "sonarr",
name: "Sonarr",
url: "http://sonarr:8989",
kind: "sonarr",
});
const input = completionInput("target");
input.selectedIntegrationIds = ["sonarr"];
await createCaller(db, claimToken).completeSetup(input);
const mediaMissing = await db.query.items.findFirst({ where: eq(items.kind, "mediaMissing") });
expect(mediaMissing).toBeDefined();
expect(
await db.query.itemLayouts.findFirst({ where: eq(itemLayouts.itemId, mediaMissing?.id ?? "missing") }),
).toMatchObject({ width: 4, height: 3 });
});
it("requires an exact board id when more than one board exists", async () => {
const db = createDb();
const claimToken = await seedOnboardingAsync(db);
@@ -206,6 +267,21 @@ describe("onboard.completeSetup", () => {
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "group" });
});
it("rejects a board rename that only differs by case from another board", async () => {
const db = createDb();
const claimToken = await seedOnboardingAsync(db);
await seedBoardAsync(db, { boardId: "target" });
await seedBoardAsync(db, { boardId: "existing" });
await db.update(boards).set({ name: "Media" }).where(eq(boards.id, "existing"));
await expect(createCaller(db, claimToken).completeSetup(completionInput("target", "media"))).rejects.toThrow(
"A board with this name already exists.",
);
expect(await db.query.boards.findFirst({ where: eq(boards.id, "target") })).toMatchObject({ name: "target" });
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "group" });
});
it("creates a complete first board when setup resumes without any boards", async () => {
const db = createDb();
const claimToken = await seedOnboardingAsync(db);
@@ -334,11 +410,12 @@ describe("onboard.completeSetup", () => {
expect(await db.query.items.findMany()).toEqual([expect.objectContaining({ boardId: "target", kind: "clock" })]);
});
it("persists discovery metadata on created apps and integration companion apps", async () => {
it("creates selected apps, integrations, secrets, and companion apps in completeSetup", async () => {
const db = createDb();
const claimToken = await seedOnboardingAsync(db);
const caller = createCaller(db, claimToken);
const appResult = await caller.createAppsFromDiscovery([
await seedBoardAsync(db, { boardId: "target" });
const input = completionInput("target");
input.apps = [
{
sourceId: "docker:good:status",
name: "Status",
@@ -347,18 +424,9 @@ describe("onboard.completeSetup", () => {
iconUrl: "https://icons.example/status.svg",
description: "Internal status page",
},
]);
const createdApp = appResult.apps[0];
assert(createdApp);
expect(await db.query.apps.findFirst({ where: eq(apps.id, createdApp.id) })).toMatchObject({
pingUrl: "http://status:3001/health",
iconUrl: "https://icons.example/status.svg",
description: "Internal status page",
});
const testConnection = vi.spyOn(integrationConnection, "testConnectionAsync").mockResolvedValue({ success: true });
try {
const integrationResult = await caller.createIntegration({
];
input.integrations = [
{
sourceId: "docker:good:sonarr",
name: "Sonarr",
url: "http://sonarr:8989",
@@ -367,45 +435,85 @@ describe("onboard.completeSetup", () => {
pingUrl: "http://sonarr:8989/ping",
iconUrl: "https://icons.example/sonarr.svg",
description: "TV automation",
},
];
const testConnection = vi.spyOn(integrationConnection, "testConnectionAsync").mockResolvedValue({ success: true });
try {
await createCaller(db, claimToken).completeSetup(input);
expect(
await db.query.apps.findFirst({ where: eq(apps.id, stableAppIdForSource("docker:good:status")) }),
).toMatchObject({
href: "http://status:3001",
pingUrl: "http://status:3001/health",
iconUrl: "https://icons.example/status.svg",
description: "Internal status page",
});
const createdAppId = "appId" in integrationResult ? integrationResult.appId : undefined;
expect(createdAppId).toBeDefined();
if (!createdAppId) throw new Error("Expected integration creation to succeed");
expect(await db.query.apps.findFirst({ where: eq(apps.id, createdAppId) })).toMatchObject({
expect(
await db.query.apps.findFirst({ where: eq(apps.id, stableAppIdForSource("docker:good:sonarr")) }),
).toMatchObject({
href: "http://sonarr:8989",
pingUrl: "http://sonarr:8989/ping",
iconUrl: "https://icons.example/sonarr.svg",
description: "TV automation",
});
expect(
await db.query.integrations.findFirst({
where: eq(integrations.id, stableIntegrationIdForSource("docker:good:sonarr")),
}),
).toMatchObject({
appId: stableAppIdForSource("docker:good:sonarr"),
name: "Sonarr",
url: "http://sonarr:8989",
kind: "sonarr",
});
const savedSecrets = await db.query.integrationSecrets.findMany({
where: eq(integrationSecrets.integrationId, stableIntegrationIdForSource("docker:good:sonarr")),
});
expect(savedSecrets).toEqual([expect.objectContaining({ kind: "apiKey" })]);
expect(savedSecrets[0]?.value).not.toBe("secret");
expect(await db.query.items.findMany({ where: eq(items.kind, "app") })).toHaveLength(2);
} finally {
testConnection.mockRestore();
}
});
it("reuses a manual integration and companion app across retries", async () => {
it("rolls draft records back with board writes and reuses stable IDs on retry", async () => {
const db = createDb();
const claimToken = await seedOnboardingAsync(db);
const caller = createCaller(db, claimToken);
await seedBoardAsync(db, { boardId: "target", itemId: "generated-id" });
const sourceId = "manual:sonarr";
const createInput = {
sourceId,
name: "Sonarr",
url: "http://sonarr:8989",
kind: "sonarr" as const,
secrets: [{ kind: "apiKey" as const, value: "secret" }],
};
const input = completionInput("target", "Retried-board");
input.integrations = [
{
sourceId,
name: "Sonarr",
url: "http://sonarr:8989",
kind: "sonarr",
secrets: [{ kind: "apiKey", value: "secret" }],
},
];
createIdMock.mockReturnValue("generated-id");
const testConnection = vi.spyOn(integrationConnection, "testConnectionAsync").mockResolvedValue({ success: true });
try {
const first = await caller.createIntegration(createInput);
const second = await caller.createIntegration(createInput);
await expect(createCaller(db, claimToken).completeSetup(input)).rejects.toThrow();
expect(await db.query.integrations.findMany()).toHaveLength(0);
expect(await db.query.apps.findMany()).toHaveLength(0);
expect(await db.query.integrationSecrets.findMany()).toHaveLength(0);
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "group" });
expect(first).toEqual(second);
expect(first).toEqual({
let nextRetryId = 0;
createIdMock.mockImplementation(() => `retry-${++nextRetryId}`);
await createCaller(db, claimToken).completeSetup(input);
expect(await db.query.integrations.findMany()).toHaveLength(1);
expect(await db.query.apps.findMany()).toHaveLength(1);
expect(await db.query.integrations.findFirst()).toMatchObject({
id: stableIntegrationIdForSource(sourceId),
appId: stableAppIdForSource(sourceId),
});
expect(await db.query.integrations.findMany()).toHaveLength(1);
expect(await db.query.apps.findMany()).toHaveLength(1);
expect(
await db.query.integrationSecrets.findMany({
where: eq(integrationSecrets.integrationId, stableIntegrationIdForSource(sourceId)),
@@ -938,6 +1046,10 @@ describe("onboard.completeSetup", () => {
});
describe("onboard.nextStep", () => {
beforeEach(() => {
isProviderEnabled.mockReturnValue(false);
});
it("advances welcome exactly once", async () => {
const db = createDb();
await db.insert(onboarding).values({ id: "onboarding", step: "start" });
@@ -950,6 +1062,29 @@ describe("onboard.nextStep", () => {
await expect(caller.nextStep()).rejects.toThrow("The welcome step is already complete.");
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "start" });
});
it("skips duplicate credential account creation when an administrator resumes a legacy step", async () => {
isProviderEnabled.mockImplementation((provider: string) => provider === "credentials");
const db = createDb();
await db.insert(onboarding).values({ id: "onboarding", step: "import" as never });
await db.insert(users).values({ id: "existing-admin", name: "admin", provider: "credentials" });
const { nextOnboardingStepAsync } = await import("../onboard/onboard-queries");
await nextOnboardingStepAsync(db);
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "setup", previousStep: "start" });
});
it("still requires credential account creation on a fresh installation", async () => {
isProviderEnabled.mockImplementation((provider: string) => provider === "credentials");
const db = createDb();
await db.insert(onboarding).values({ id: "onboarding", step: "start" });
const { nextOnboardingStepAsync } = await import("../onboard/onboard-queries");
await nextOnboardingStepAsync(db);
expect(await db.query.onboarding.findFirst()).toMatchObject({ step: "user", previousStep: "start" });
});
});
describe("normalizeOnboardingStep", () => {
@@ -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<UrlTemplateMode, () => 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]();
};
+14 -4
View File
@@ -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;
}
};
/**
@@ -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", () => {
@@ -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",
@@ -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);
@@ -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();
}
@@ -9,20 +9,6 @@ import type { DockerDiscoveryHostResult, DockerDiscoveryResult } from "./types";
const logger = createLogger({ module: "dockerDiscovery" });
export const dockerDiscoveryHostTimeoutMs = 5_000;
const withTimeoutAsync = async <T>(promise: Promise<T>, timeoutMs: number, host: string) => {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<never>((_, 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<DockerDiscoveryHostResult> => {
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<typeof setTimeout> | undefined;
try {
const containers = await Promise.race([
instance.listContainers({ all: false, abortSignal: controller.signal }),
new Promise<never>((_, 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);
}
}),
);
@@ -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);
});
});
@@ -0,0 +1,7 @@
interface CredentialsSignInResult {
ok: boolean;
error?: string | null;
}
export const didCredentialsSignInFail = (result: CredentialsSignInResult | undefined) =>
result === undefined || !result.ok || Boolean(result.error);
+38 -4
View File
@@ -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<HTMLAnchorElement>(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<typeof userInitSchema>) => {
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 (
<AccountShell title={t("recoveryTitle")} description={t("recoveryDescription")}>
<Button
component={Link}
href="/auth/login?callbackUrl=/init"
ref={recoveryActionRef}
size="md"
rightSection={<IconArrowRight size={18} />}
>
{t("recoveryAction")}
</Button>
</AccountShell>
);
}
return (
<AccountShell title={t("title")} description={t("description")}>
<form onSubmit={form.onSubmit((values) => void submitAsync(values))}>
@@ -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);
});
});
+14
View File
@@ -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";
};
@@ -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<string>();
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);
});
});
@@ -0,0 +1,21 @@
export const takeNewSourceIds = (sourceIds: string[], seenSourceIds: Set<string>) =>
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;
}
};
+1 -3
View File
@@ -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 (
<main className={classes.page}>
@@ -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;
}
+6 -18
View File
@@ -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<string | null>(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";
File diff suppressed because it is too large. Load diff
-2
View File
@@ -5,11 +5,9 @@ import type { OnboardingStep } from "@homarr/definitions";
export interface OnboardingEnvironment {
currentStep: OnboardingStep;
databaseDriver: "sqlite" | "mysql" | "postgresql";
credentialsEnabled: boolean;
externalAuthEnabled: boolean;
dockerConfigured: boolean;
kubernetesConfigured: boolean;
workshopEnabled: boolean;
workshopApiUrl: string;
workshopUrl: string;
mcpEndpoint: string;
+1 -1
View File
@@ -74,7 +74,7 @@ export const Welcome = ({ environment, sqliteRestore }: OnboardingStudioProps) =
<section className={classes.hero} aria-labelledby="onboarding-welcome-title">
<Stack gap="xl" align="flex-start">
<Stack gap="md">
<Text c="dimmed" fw={650} tt="uppercase" fz="xs" lts="0.14em">
<Text c="dimmed" fw={650} size="sm">
{t("eyebrow")}
</Text>
<Title id="onboarding-welcome-title" className={classes.heroTitle}>
+31 -16
View File
@@ -53,7 +53,9 @@
"username": "Administrator username",
"create": "Create administrator",
"errorTitle": "Administrator could not be created",
"signInError": "The account was created, but automatic sign-in failed. Sign in and setup will resume.",
"recoveryTitle": "Administrator account created",
"recoveryDescription": "Automatic sign-in did not complete. Sign in with the administrator credentials you just created to continue setup.",
"recoveryAction": "Sign in and continue",
"unknownError": "The administrator could not be created."
},
"externalGroup": {
@@ -65,7 +67,6 @@
"errorTitle": "Group could not be saved"
},
"essentials": {
"eyebrow": "01 · Your Homarr",
"title": "Start with familiar defaults",
"description": "Set the language and appearance everyone sees first, then give Homarr the address pattern used by your services.",
"language": "Default language",
@@ -76,14 +77,14 @@
"serverTitle": "Usual server address",
"serverDescription": "This is a shortcut, not a restriction. Every detected service remains editable.",
"serverOrigin": "Domain, IP, or reverse-proxy path",
"serverOriginDescription": "Examples: home.lan, 192.168.1.10, or https://homarr.example.com. URL paths are ignored when suggesting service addresses.",
"serverOriginDescription": "Examples: home.lan, 192.168.1.10, or https://home.example.com/services. Choose how each service is appended below.",
"hostPort": "Same host, different ports",
"subdomain": "One subdomain per service",
"path": "One path per service",
"analytics": "Share anonymous product analytics",
"analyticsDescription": "Helps Homarr improve feature reliability. No service credentials or dashboard content are sent."
},
"discover": {
"eyebrow": "02 · Capabilities",
"title": "See what this installation can actually reach",
"description": "Homarr checks capabilities in place. A missing Docker socket or Kubernetes context never blocks a manual setup.",
"database": "Database",
@@ -92,13 +93,17 @@
"assistantWorkshop": "Assistant and Workshop",
"found": "Found {integrations} integrations and {apps} additional apps",
"retryDocker": "Scan Docker again",
"kubernetesAvailable": "Kubernetes tooling is enabled for this installation",
"retryKubernetes": "Check Kubernetes again",
"retryWorkshop": "Check Workshop again",
"unknownVersion": "unknown version",
"kubernetesAvailable": "Connected to Kubernetes {version}",
"kubernetesDisabled": "Not enabled; continue with Docker or manual services",
"kubernetesUnavailable": "Kubernetes is enabled but cannot be reached from this Homarr process",
"workshopAvailable": "Community Workshop and hosted Assistant services are reachable",
"workshopUnavailable": "Local and custom Assistant providers remain available",
"partialTitle": "Some sources could not be scanned",
"partialDescription": "Results from reachable hosts are kept. Check socket permissions or add services manually in the next section.",
"platformTitle": "Docker, Synology, Kubernetes, bare metal—all welcome",
"platformTitle": "Docker, Synology, Kubernetes, and bare metal are all welcome",
"platformDescription": "Homarr uses capabilities rather than guessing your platform. Detected values are suggestions you can review before anything is created.",
"status": {
"checking": "Checking availability…",
@@ -110,14 +115,19 @@
}
},
"connect": {
"eyebrow": "03 · Services",
"title": "Connect what makes the board useful",
"description": "Select several services at once. Docker labels take priority, image matching fills gaps, and every URL and credential stays reviewable.",
"search": "Search integrations",
"widgets": "{count} widgets",
"detected": "Detected",
"discoveredIntegrations": "Detected integration instances ({count})",
"detectedIntegrationLabel": "Import {name}, detected as {kind}",
"detectedAppLabel": "Import detected app {name}",
"otherIntegrations": "Add another integration",
"discoveredApps": "Additional detected apps ({count})",
"addressNeeded": "Address needed",
"appUrl": "Address for {name}",
"appAddressRequired": "Add an HTTP or HTTPS address for {name}, or remove it from the selection.",
"empty": "No integrations selected. Homarr will still create a useful starter board with general widgets.",
"dockerSource": "Docker",
"manualSource": "Manual",
@@ -127,12 +137,9 @@
"noCredentialsDescription": "This integration does not need credentials.",
"getApiKey": "Open the API key settings for this service",
"openGuide": "Open Homarr's setup guide for this service",
"ready": "Ready",
"validationError": "Complete the URL and required credentials for {name}, or remove it from the selection.",
"connectionError": "Homarr could not connect to {name}. Check its URL and credentials; the rest of setup was not discarded."
"validationError": "Complete the URL and required credentials for {name}, or remove it from the selection."
},
"board": {
"eyebrow": "04 · First board",
"title": "Shape the board before it is built",
"description": "Homarr creates protected Mobile and Base layouts, then places apps and compatible widgets from the services you selected.",
"target": "Board to configure",
@@ -144,12 +151,16 @@
"primaryColor": "Primary color",
"secondaryColor": "Secondary color",
"radius": "Widget corner radius",
"layoutPreset": "Board width preset",
"layoutFocused": "Focused",
"layoutBalanced": "Balanced",
"layoutWide": "Wide",
"leftSidebar": "Left sidebar",
"rightSidebar": "Right sidebar",
"previewLabel": "Live preview of the first board layout",
"previewLayout": "Layout to preview",
"previewMobile": "Mobile",
"previewBase": "Base",
"widgetPrimer": "A 30-second Homarr primer",
"primerAppsTitle": "Apps open things",
"primerAppsDescription": "App tiles are fast links with icons, status, and optional ping checks.",
@@ -159,9 +170,9 @@
"primerEditDescription": "Enter edit mode to move, resize, group, or add items independently on Mobile and Base layouts."
},
"extend": {
"eyebrow": "05 · Power tools",
"title": "Add intelligence when it helps",
"description": "Assistant, Workshop, and MCP work together but remain independent. Configure any now or return later.",
"tabsLabel": "Assistant, Workshop, and MCP setup",
"assistant": "Assistant",
"workshop": "Workshop",
"mcp": "MCP",
@@ -178,6 +189,8 @@
"connectHint": "Keys are encrypted and never returned by the API.",
"model": "Default model",
"modelPlaceholder": "Connect a provider first",
"manualModelPlaceholder": "Enter a model ID",
"manualModelDescription": "Model discovery is unavailable. Enter the exact model ID exposed by your provider.",
"discovering": "Discovering models…",
"enable": "Enable Assistant",
"webSearch": "Allow supported web search",
@@ -191,6 +204,7 @@
"title": "Homarr Workshop",
"description": "Browse community Custom Widgets, inspect their requested capabilities, install them, and publish your own work without copying code between servers.",
"available": "Available",
"checking": "Checking…",
"unavailable": "Unavailable",
"discoverTitle": "Discover",
"discoverDescription": "Find widgets built for real self-hosted workflows.",
@@ -216,7 +230,6 @@
}
},
"review": {
"eyebrow": "06 · Review",
"title": "One board, built around your services",
"description": "Homarr tests unsaved connections, imports the selected apps, applies appearance and layouts, then opens the exact board it configured.",
"appearance": "Language and appearance",
@@ -232,9 +245,7 @@
"dockerWarningDescription": "{count, plural, one {# selected Docker result could not be applied because its service changed, targeted another board, or its widget was incompatible} other {# selected Docker results could not be applied because services changed, targeted another board, or their widgets were incompatible}}.",
"errorTitle": "Setup paused safely",
"progress": {
"integration": "Testing and saving {name}",
"apps": "Importing selected apps",
"board": "Building Mobile and Base layouts",
"applying": "Testing connections and building your board",
"done": "Your board is ready"
}
},
@@ -6822,6 +6833,10 @@
"title": "Restore Complete",
"message": "Database has been restored successfully. The server is restarting — please refresh the page in a few seconds."
},
"timeout": {
"title": "Server did not come back online",
"message": "The database restore finished, but Homarr did not become ready again. Retry the connection check or refresh this page manually."
},
"failed": {
"title": "Restore Failed"
},
+26 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, test } from "vitest";
import { onboardingCreateIntegrationSchema, onboardingDiscoveredAppSchema } from "./onboarding";
import {
onboardingCompleteSetupSchema,
onboardingCreateIntegrationSchema,
onboardingDiscoveredAppSchema,
} from "./onboarding";
describe("onboarding URL validation", () => {
test.each(["javascript:alert(1)", "data:text/html,hello", "file:///etc/passwd"])(
@@ -53,4 +57,25 @@ describe("onboarding URL validation", () => {
}).success,
).toBe(false);
});
test("requires every app submitted to completeSetup to have an HTTP(S) href", () => {
const input = {
server: { defaultLocale: "en", defaultColorScheme: "dark" },
board: {
name: "dashboard",
primaryColor: "#228BE6",
secondaryColor: "#15AABF",
itemRadius: "md",
},
apps: [{ sourceId: "docker:status", name: "Status", href: null, iconUrl: null }],
};
expect(onboardingCompleteSetupSchema.safeParse(input).success).toBe(false);
expect(
onboardingCompleteSetupSchema.safeParse({
...input,
apps: [{ ...input.apps[0], href: "https://status.example.com" }],
}).success,
).toBe(true);
});
});
+80 -29
View File
@@ -1,9 +1,16 @@
import { z } from "zod/v4";
import { colorSchemes, onboardingLayoutPresets, widgetKinds } from "@homarr/definitions";
import {
colorSchemes,
integrationKinds,
integrationSecretKinds,
onboardingLayoutPresets,
widgetKinds,
} from "@homarr/definitions";
import { supportedLanguages } from "@homarr/translation/languages";
import { boardNameSchema } from "./board";
import { zodEnumFromArray } from "./enums";
const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
const httpUrlSchema = z.url().refine((value) => {
@@ -14,44 +21,30 @@ const uniqueIdsSchema = z
.array(z.string().trim().min(1).max(255))
.max(128)
.refine((ids) => new Set(ids).size === ids.length, { message: "IDs must be unique" });
export const onboardingCompleteSetupSchema = z.object({
server: z.object({
defaultLocale: z.enum(supportedLanguages),
defaultColorScheme: z.enum(colorSchemes),
analyticsEnabled: z.boolean().optional(),
}),
board: z.object({
id: z.string().trim().min(1).max(255).optional(),
name: boardNameSchema,
primaryColor: hexColorSchema,
secondaryColor: hexColorSchema,
itemRadius: z.enum(["xs", "sm", "md", "lg", "xl"]),
layoutPreset: z.enum(onboardingLayoutPresets).default("balanced"),
leftSidebar: z.boolean().default(false),
rightSidebar: z.boolean().default(false),
}),
selectedIntegrationIds: uniqueIdsSchema.default([]),
selectedAppIds: uniqueIdsSchema.default([]),
selectedDockerSourceIds: uniqueIdsSchema.default([]),
selectedWidgetKinds: z
.array(z.enum(widgetKinds))
.max(widgetKinds.length)
.refine((kinds) => new Set(kinds).size === kinds.length, { message: "Widget kinds must be unique" })
.default([]),
});
const sourceIdSchema = z.string().trim().min(1).max(512);
export const onboardingCreateIntegrationSchema = z.object({
name: z.string().trim().min(1).max(127),
url: httpUrlSchema,
sourceId: z.string().trim().min(1).max(512).optional(),
sourceId: sourceIdSchema.optional(),
iconUrl: z.string().trim().min(1).max(2048).nullable().optional(),
description: z.string().trim().max(512).nullable().optional(),
pingUrl: httpUrlSchema.nullable().optional(),
});
export const onboardingIntegrationDraftSchema = onboardingCreateIntegrationSchema.extend({
sourceId: sourceIdSchema,
kind: zodEnumFromArray(integrationKinds),
secrets: z.array(
z.object({
kind: zodEnumFromArray(integrationSecretKinds),
value: z.string().nonempty(),
}),
),
});
export const onboardingDiscoveredAppSchema = z.object({
sourceId: z.string().trim().min(1).max(512).optional(),
sourceId: sourceIdSchema.optional(),
name: z.string().trim().min(1).max(127),
href: httpUrlSchema.nullable(),
pingUrl: httpUrlSchema.nullable().optional(),
@@ -59,4 +52,62 @@ export const onboardingDiscoveredAppSchema = z.object({
description: z.string().trim().max(512).nullable().optional(),
});
export const onboardingAppDraftSchema = onboardingDiscoveredAppSchema.extend({
sourceId: sourceIdSchema,
href: httpUrlSchema,
});
const integrationDraftsSchema = z
.array(onboardingIntegrationDraftSchema)
.max(128)
.refine((drafts) => new Set(drafts.map((draft) => draft.sourceId)).size === drafts.length, {
message: "Integration source IDs must be unique",
});
const appDraftsSchema = z
.array(onboardingAppDraftSchema)
.max(128)
.refine((drafts) => new Set(drafts.map((draft) => draft.sourceId)).size === drafts.length, {
message: "App source IDs must be unique",
});
export const onboardingCompleteSetupSchema = z
.object({
server: z.object({
defaultLocale: z.enum(supportedLanguages),
defaultColorScheme: z.enum(colorSchemes),
analyticsEnabled: z.boolean().optional(),
}),
board: z.object({
id: z.string().trim().min(1).max(255).optional(),
name: boardNameSchema,
primaryColor: hexColorSchema,
secondaryColor: hexColorSchema,
itemRadius: z.enum(["xs", "sm", "md", "lg", "xl"]),
layoutPreset: z.enum(onboardingLayoutPresets).default("balanced"),
leftSidebar: z.boolean().default(false),
rightSidebar: z.boolean().default(false),
}),
selectedIntegrationIds: uniqueIdsSchema.default([]),
selectedAppIds: uniqueIdsSchema.default([]),
integrations: integrationDraftsSchema.default([]),
apps: appDraftsSchema.default([]),
selectedDockerSourceIds: uniqueIdsSchema.default([]),
selectedWidgetKinds: z
.array(z.enum(widgetKinds))
.max(widgetKinds.length)
.refine((kinds) => new Set(kinds).size === kinds.length, { message: "Widget kinds must be unique" })
.default([]),
})
.superRefine((input, ctx) => {
const integrationSourceIds = new Set(input.integrations.map((integration) => integration.sourceId));
for (const [index, app] of input.apps.entries()) {
if (!integrationSourceIds.has(app.sourceId)) continue;
ctx.addIssue({
code: "custom",
message: "A source ID cannot be used by both an integration and an app",
path: ["apps", index, "sourceId"],
});
}
});
export type OnboardingCompleteSetupInput = z.infer<typeof onboardingCompleteSetupSchema>;