fix(onboarding): accept self-hosted addresses

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:32:13 +02:00
1 parent 9c5ab62b3a
commit f1ce0fffad
5 files changed
+43 -29

No files matched your search

@@ -52,6 +52,7 @@ services:
Use an address that is reachable where it is needed. A Docker service name can work for server-side integration
requests, while an app link opened in a browser usually needs a LAN hostname, IP address, or reverse-proxy address.
Onboarding accepts these self-hosted address formats directly and only requires the field to be non-empty.
## Homepage label fallback
+22 -6
View File
@@ -100,7 +100,6 @@ import { OnboardingBackdrop } from "./onboarding-backdrop";
import { OnboardingWordmark } from "./onboarding-wordmark";
import { useOnboardingSounds } from "./use-onboarding-sounds";
import {
isHttpUrl,
normalizeServiceUrl,
resolveDiscoveredAppUrl,
resolveIntegrationDraftUrl,
@@ -127,7 +126,7 @@ interface IntegrationDraft {
}
const isIntegrationDraftComplete = (draft: IntegrationDraft) =>
isHttpUrl(draft.url) && draft.secrets.every((secret) => secret.value.trim().length > 0);
draft.url.trim().length > 0 && draft.secrets.every((secret) => secret.value.trim().length > 0);
type DockerDiscoveryData = RouterOutputs["onboard"]["discoverDockerServices"];
type RuntimeCapabilitiesQuery = Omit<
@@ -435,7 +434,9 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
}
const draftsToApply = drafts.filter(isIntegrationDraftComplete);
const appsToCreate = discoveredApps.filter((app) => selectedAppIds.includes(app.sourceId));
const appWithoutAddress = appsToCreate.find((app) => !isHttpUrl(discoveredAppUrls[app.sourceId] ?? ""));
const appWithoutAddress = appsToCreate.find(
(app) => (discoveredAppUrls[app.sourceId] ?? "").trim().length === 0,
);
if (appWithoutAddress) {
const message = t("connect.appAddressRequired", { name: appWithoutAddress.containerName });
setAppError(message);
@@ -493,7 +494,8 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
};
}),
apps: appsToCreate.flatMap((app) => {
const href = normalizeServiceUrl(discoveredAppUrls[app.sourceId] ?? "");
const enteredAddress = (discoveredAppUrls[app.sourceId] ?? "").trim();
const href = normalizeServiceUrl(enteredAddress) ?? enteredAddress;
return href
? [
{
@@ -635,13 +637,27 @@ export const SetupStudio = ({ environment, assistantConfiguration }: OnboardingS
appErrorSourceId,
};
const hasIncompleteIntegrations = drafts.some((draft) => !isIntegrationDraftComplete(draft));
const selectedAppWithoutAddress = discoveredApps.find(
(app) => selectedAppIds.includes(app.sourceId) && (discoveredAppUrls[app.sourceId] ?? "").trim().length === 0,
);
const continueDisabled = isApplying || (activeSection === "essentials" && serverOrigin.trim().length === 0);
const continueOnboarding = () => {
if (continueDisabled) return;
if (activeSection === "review") {
void applySetupAsync();
} else if (activeSection === "connect" && hasIncompleteIntegrations) {
setIncompleteIntegrationConfirmationOpened(true);
} else if (activeSection === "connect") {
if (selectedAppWithoutAddress) {
setAppError(t("connect.appAddressRequired", { name: selectedAppWithoutAddress.containerName }));
setAppErrorSourceId(selectedAppWithoutAddress.sourceId);
return;
}
setAppError(null);
setAppErrorSourceId(null);
if (hasIncompleteIntegrations) {
setIncompleteIntegrationConfirmationOpened(true);
} else {
move(1);
}
} else {
move(1);
}
+1 -1
View File
@@ -114,7 +114,7 @@
"otherIntegrations": "Add another integration",
"configureIntegrations": "Configure integrations",
"discoveredApps": "Detected apps ({count})",
"appAddressRequired": "Add an HTTP or HTTPS address for {name}, or remove it from the selection.",
"appAddressRequired": "Add an 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",
+13 -13
View File
@@ -6,11 +6,11 @@ import {
onboardingDiscoveredAppSchema,
} from "./onboarding";
describe("onboarding URL validation", () => {
test.each(["javascript:alert(1)", "data:text/html,hello", "file:///etc/passwd"])(
"rejects the %s scheme for integrations",
describe("onboarding address validation", () => {
test.each(["server.local:8989", "0.0.0.0:3000", "sonarr", "/services/sonarr"])(
"accepts the self-hosted address %s",
(url) => {
expect(onboardingCreateIntegrationSchema.safeParse({ name: "Unsafe", url }).success).toBe(false);
expect(onboardingCreateIntegrationSchema.safeParse({ name: "Service", url }).success).toBe(true);
},
);
@@ -27,7 +27,7 @@ describe("onboarding URL validation", () => {
).toBe(true);
});
test("accepts bounded integration metadata and rejects invalid metadata", () => {
test("accepts bounded integration metadata and rejects empty or oversized metadata", () => {
expect(
onboardingCreateIntegrationSchema.safeParse({
name: "Sonarr",
@@ -41,7 +41,7 @@ describe("onboarding URL validation", () => {
onboardingCreateIntegrationSchema.safeParse({
name: "Sonarr",
url: "http://sonarr:8989",
pingUrl: "file:///etc/passwd",
pingUrl: "",
}).success,
).toBe(false);
expect(
@@ -53,18 +53,18 @@ describe("onboarding URL validation", () => {
).toBe(false);
});
test("rejects non-web app and ping URLs", () => {
test("accepts custom app and ping addresses", () => {
expect(
onboardingDiscoveredAppSchema.safeParse({
name: "Unsafe",
href: "javascript:alert(1)",
pingUrl: "file:///etc/passwd",
name: "Database",
href: "postgres://database:5432",
pingUrl: "database.local:5432",
iconUrl: null,
}).success,
).toBe(false);
).toBe(true);
});
test("requires every app submitted to completeSetup to have an HTTP(S) href", () => {
test("requires every app submitted to completeSetup to have a non-empty address", () => {
const input = {
server: { defaultLocale: "en", defaultColorScheme: "dark" },
board: {
@@ -80,7 +80,7 @@ describe("onboarding URL validation", () => {
expect(
onboardingCompleteSetupSchema.safeParse({
...input,
apps: [{ ...input.apps[0], href: "https://status.example.com" }],
apps: [{ ...input.apps[0], href: "status.local:3001" }],
}).success,
).toBe(true);
});
+6 -9
View File
@@ -13,10 +13,7 @@ import { boardNameSchema } from "./board";
import { zodEnumFromArray } from "./enums";
const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/);
const httpUrlSchema = z.url().refine((value) => {
const protocol = new URL(value).protocol;
return protocol === "http:" || protocol === "https:";
}, "Only HTTP and HTTPS URLs are permitted");
const serviceAddressSchema = z.string().trim().min(1).max(2048);
const uniqueIdsSchema = (maxLength = 255) =>
z
.array(z.string().trim().min(1).max(maxLength))
@@ -26,11 +23,11 @@ const sourceIdSchema = z.string().trim().min(1).max(512);
export const onboardingCreateIntegrationSchema = z.object({
name: z.string().trim().min(1).max(127),
url: httpUrlSchema,
url: serviceAddressSchema,
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(),
pingUrl: serviceAddressSchema.nullable().optional(),
});
export const onboardingIntegrationDraftSchema = onboardingCreateIntegrationSchema.extend({
@@ -47,15 +44,15 @@ export const onboardingIntegrationDraftSchema = onboardingCreateIntegrationSchem
export const onboardingDiscoveredAppSchema = z.object({
sourceId: sourceIdSchema.optional(),
name: z.string().trim().min(1).max(127),
href: httpUrlSchema.nullable(),
pingUrl: httpUrlSchema.nullable().optional(),
href: serviceAddressSchema.nullable(),
pingUrl: serviceAddressSchema.nullable().optional(),
iconUrl: z.string().trim().min(1).nullable(),
description: z.string().trim().max(512).nullable().optional(),
});
export const onboardingAppDraftSchema = onboardingDiscoveredAppSchema.extend({
sourceId: sourceIdSchema,
href: httpUrlSchema,
href: serviceAddressSchema,
});
const integrationDraftsSchema = z