fix(onboarding): accept self-hosted addresses
This commit is contained in:
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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user