mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-17 09:51:14 -05:00
Compare commits
1 Commits
fix/segmen
...
fix/url-va
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7b3c5ac50 |
@@ -38,6 +38,7 @@ export const ActionSettingsTab = ({
|
||||
setOpen,
|
||||
isReadOnly,
|
||||
}: ActionSettingsTabProps) => {
|
||||
const actionDocsHref = "https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions";
|
||||
const { createdAt, updatedAt, id, ...restActionClass } = actionClass;
|
||||
const router = useRouter();
|
||||
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
|
||||
@@ -146,7 +147,7 @@ export const ActionSettingsTab = ({
|
||||
|
||||
<div className="flex justify-between gap-x-2 border-slate-200 pt-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
{!isReadOnly ? (
|
||||
{isReadOnly ? null : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
@@ -155,22 +156,22 @@ export const ActionSettingsTab = ({
|
||||
<TrashIcon />
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
<Button variant="secondary" asChild>
|
||||
<Link href="https://formbricks.com/docs/actions/no-code" target="_blank">
|
||||
<Link href={actionDocsHref} target="_blank">
|
||||
{t("common.read_docs")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!isReadOnly ? (
|
||||
{isReadOnly ? null : (
|
||||
<div className="flex space-x-2">
|
||||
<Button type="submit" loading={isUpdatingAction}>
|
||||
{t("common.save_changes")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { TActionClass, TActionClassInput } from "@formbricks/types/action-classes";
|
||||
import {
|
||||
createActionClassZodResolver,
|
||||
@@ -45,9 +44,9 @@ const mockT = vi.fn((key: string, params?: any) => {
|
||||
|
||||
// Helper to create mock context
|
||||
const createMockContext = () => {
|
||||
const issues: z.ZodIssue[] = [];
|
||||
const issues: unknown[] = [];
|
||||
return {
|
||||
addIssue: vi.fn((issue: z.ZodIssue) => issues.push(issue)),
|
||||
addIssue: vi.fn((issue: unknown) => issues.push(issue)),
|
||||
issues,
|
||||
} as any;
|
||||
};
|
||||
|
||||
@@ -50,7 +50,11 @@ export const CodeActionForm = ({ form, isReadOnly }: CodeActionFormProps) => {
|
||||
formbricks.track("{watch("key")}")
|
||||
</span>{" "}
|
||||
{t("environments.actions.in_your_code_read_more_in_our")}{" "}
|
||||
<a href="https://formbricks.com/docs/actions/code" target="_blank" className="underline">
|
||||
<a
|
||||
href="https://formbricks.com/docs/xm-and-surveys/surveys/website-app-surveys/actions"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline">
|
||||
{t("common.docs")}
|
||||
</a>
|
||||
{"."}
|
||||
|
||||
42
packages/types/action-classes.test.ts
Normal file
42
packages/types/action-classes.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { ZActionClassNoCodeConfig, isValidActionClassUrlFilterValue } from "./action-classes";
|
||||
|
||||
describe("isValidActionClassUrlFilterValue", () => {
|
||||
test("accepts valid absolute URLs for URL-like rules", () => {
|
||||
expect(isValidActionClassUrlFilterValue("https://example.com/dashboard", "exactMatch")).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects hostnames without a valid TLD", () => {
|
||||
expect(isValidActionClassUrlFilterValue("https://ok", "exactMatch")).toBe(false);
|
||||
expect(isValidActionClassUrlFilterValue("https://ok.", "exactMatch")).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts localhost and root-relative paths", () => {
|
||||
expect(isValidActionClassUrlFilterValue("http://localhost:3000/dashboard", "exactMatch")).toBe(true);
|
||||
expect(isValidActionClassUrlFilterValue("/dashboard", "startsWith")).toBe(true);
|
||||
});
|
||||
|
||||
test("skips URL-shape validation for free-form rules", () => {
|
||||
expect(isValidActionClassUrlFilterValue("dashboard", "contains")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ZActionClassNoCodeConfig", () => {
|
||||
test("rejects invalid URL-like filters", () => {
|
||||
const result = ZActionClassNoCodeConfig.safeParse({
|
||||
type: "pageView",
|
||||
urlFilters: [{ rule: "exactMatch", value: "https://ok." }],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts valid URL-like filters", () => {
|
||||
const result = ZActionClassNoCodeConfig.safeParse({
|
||||
type: "pageView",
|
||||
urlFilters: [{ rule: "exactMatch", value: "https://example.com/dashboard" }],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -26,16 +26,56 @@ export const ZActionClassPageUrlRule = z.enum(ACTION_CLASS_PAGE_URL_RULES);
|
||||
|
||||
export type TActionClassPageUrlRule = z.infer<typeof ZActionClassPageUrlRule>;
|
||||
|
||||
const URL_LIKE_FILTER_RULES = new Set<TActionClassPageUrlRule>(["exactMatch", "startsWith", "notMatch"]);
|
||||
const DOMAIN_HOSTNAME_REGEX = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/;
|
||||
|
||||
const isValidAbsoluteUrlFilterValue = (value: string): boolean => {
|
||||
try {
|
||||
const parsedUrl = new URL(value);
|
||||
|
||||
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isIPv6 = parsedUrl.hostname.startsWith("[") && parsedUrl.hostname.endsWith("]");
|
||||
const isIPv4 = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(parsedUrl.hostname);
|
||||
|
||||
return (
|
||||
DOMAIN_HOSTNAME_REGEX.test(parsedUrl.hostname) || parsedUrl.hostname === "localhost" || isIPv6 || isIPv4
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const isValidActionClassUrlFilterValue = (value: string, rule: TActionClassPageUrlRule): boolean => {
|
||||
if (!URL_LIKE_FILTER_RULES.has(rule)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return value.startsWith("/") || isValidAbsoluteUrlFilterValue(value);
|
||||
};
|
||||
|
||||
const ZActionClassUrlFilter = z
|
||||
.object({
|
||||
value: z.string().trim().min(1, {
|
||||
error: "Value must contain atleast 1 character",
|
||||
}),
|
||||
rule: ZActionClassPageUrlRule,
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (!isValidActionClassUrlFilterValue(data.value, data.rule)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["value"],
|
||||
message: "Please enter a valid URL (e.g., https://example.com)",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const ZActionClassNoCodeConfigBase = z.object({
|
||||
type: z.enum(["click", "pageView", "exitIntent", "fiftyPercentScroll", "pageDwell"]),
|
||||
urlFilters: z.array(
|
||||
z.object({
|
||||
value: z.string().trim().min(1, {
|
||||
error: "Value must contain atleast 1 character",
|
||||
}),
|
||||
rule: ZActionClassPageUrlRule,
|
||||
})
|
||||
),
|
||||
urlFilters: z.array(ZActionClassUrlFilter),
|
||||
urlFiltersConnector: z.enum(["or", "and"]).optional(),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user