Compare commits

...

11 Commits

Author SHA1 Message Date
pandeymangg
e2df25c587 adds server only to validate-webhook-url 2026-03-05 18:57:38 +05:30
pandeymangg
fc303970b9 Merge branch 'main' into fix/webhook-dispatch 2026-03-05 18:57:15 +05:30
Matti Nannt
32eda35a71 chore: clean up stale turbo task config (#7423) 2026-03-05 11:49:24 +00:00
Dhruwang Jariwala
84999cddfd feat: danish support to surveys package (#7415) 2026-03-05 11:05:40 +00:00
Matti Nannt
f0a0cf531a chore: clean up unused npm dependencies (#7417)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-05 10:48:13 +00:00
Matti Nannt
f3e02fa466 chore: optimize monorepo build performance (#7419) 2026-03-05 10:18:54 +00:00
Dhruwang Jariwala
f0a93ae092 fix: add Tailwind v3 config for Prettier in apps/web and packages/email (#7421) 2026-03-05 10:05:05 +00:00
Matti Nannt
1c922dfe2c chore: remove legacy post-checkout hook (#7418) 2026-03-05 08:14:19 +00:00
Bhagya Amarasinghe
805f3f0a93 fix: handle hex-encoded IPv4-mapped IPv6 addresses in SSRF validation
Addresses bypass where ::ffff:c0a8:0101 (hex form of 192.168.1.1) was
not detected as private. The new isIPv4Mapped helper parses both dotted
and hex-encoded suffixes after ::ffff: and checks them against the
existing private IPv4 patterns.

Made-with: Cursor
2026-03-05 13:11:43 +05:30
Bhagya Amarasinghe
0584a7df78 test: verify SSRF validation is called and errors prevent Prisma writes
Add integration tests to webhook service test suites ensuring
validateWebhookUrl is invoked with the correct URL and that validation
failures (InvalidInputError) short-circuit before reaching the database.

Made-with: Cursor
2026-03-05 13:07:44 +05:30
Bhagya Amarasinghe
cff8368f87 fix: add server-side SSRF validation for webhook URLs 2026-03-05 00:36:38 +05:30
157 changed files with 1249 additions and 942 deletions

View File

@@ -1,2 +0,0 @@
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
prettier --write ./branch.json

View File

@@ -10,9 +10,6 @@
"build-storybook": "storybook build",
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"@formbricks/survey-ui": "workspace:*"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "10.2.14",
@@ -23,10 +20,8 @@
"@tailwindcss/vite": "4.2.1",
"@typescript-eslint/parser": "8.56.1",
"@vitejs/plugin-react": "5.1.4",
"esbuild": "0.27.3",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.14",
"prop-types": "15.8.1",
"storybook": "10.2.14",
"vite": "7.3.1",
"@storybook/addon-docs": "10.2.14"

6
apps/web/.prettierrc.js Normal file
View File

@@ -0,0 +1,6 @@
const baseConfig = require("../../.prettierrc.js");
module.exports = {
...baseConfig,
tailwindConfig: "./tailwind.config.js",
};

View File

@@ -69,7 +69,7 @@ export const ConnectWithFormbricks = ({
) : (
<div className="flex animate-pulse flex-col items-center space-y-4">
<span className="relative flex h-10 w-10">
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-slate-400 opacity-75"></span>
<span className="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-slate-400 opacity-75"></span>
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
</span>
<p className="pt-4 text-sm font-medium text-slate-600">

View File

@@ -46,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel}
/>
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}`}>

View File

@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}/surveys`}>

View File

@@ -42,7 +42,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
return (
<aside
className={cn(
"w-sidebar-collapsed z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
)}>
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />

View File

@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -69,7 +69,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/>
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -188,7 +188,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />

View File

@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
<currentStatus.icon />
</div>
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
{status === "notImplemented" && (
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
<RotateCcwIcon />

View File

@@ -98,7 +98,7 @@ export const PasswordConfirmationModal = ({
aria-label="password"
aria-required="true"
required
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
value={field.value}
onChange={(password) => field.onChange(password)}
/>

View File

@@ -77,7 +77,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
)}>
<button
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
className="m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline hover:outline-brand-dark"
onClick={() =>
setFilter(
elementSummary.element.id,

View File

@@ -158,7 +158,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
}>
<div className="flex h-32 w-full flex-col items-center justify-end">
<div
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
className="w-full rounded-t-lg border border-slate-200 bg-brand-dark transition-all group-hover:brightness-110"
style={{
height: `${Math.max(choice.percentage, 2)}%`,
opacity,

View File

@@ -116,7 +116,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
)
}>
<div
className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
style={{ opacity }}
/>
</ClickableBarSegment>

View File

@@ -105,7 +105,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
<div className={scriptsMode === "replace" ? "opacity-50" : ""}>
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
<pre className="font-mono text-xs whitespace-pre-wrap text-slate-600">
<pre className="whitespace-pre-wrap font-mono text-xs text-slate-600">
{projectCustomScripts}
</pre>
</div>
@@ -135,7 +135,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
rows={8}
placeholder={t("environments.surveys.share.custom_html.placeholder")}
className={cn(
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
"flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
)}
{...field}
disabled={isReadOnly}

View File

@@ -66,7 +66,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.use_personal_links")}
<Badge size="normal" type="success" className="absolute top-3 right-3" text={t("common.new")} />
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
</button>
<Link
href={`/environments/${environmentId}/settings/notifications`}

View File

@@ -192,7 +192,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
value={inputValue}
onValueChange={setInputValue}
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none ring-offset-transparent outline-none focus:border-none focus:shadow-none focus:ring-offset-0 focus:outline-none"
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
/>
)}
<Button

View File

@@ -10,7 +10,7 @@ const Loading = () => {
<div className="mt-6 p-6">
<GoBackButton />
<div className="mb-6 text-right">
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
{t("environments.integrations.google_sheets.link_new_sheet")}
</Button>
</div>
@@ -51,7 +51,7 @@ const Loading = () => {
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
</div>
<div className="text-center"></div>

View File

@@ -10,7 +10,7 @@ const Loading = () => {
<div className="mt-6 p-6">
<GoBackButton />
<div className="mb-6 text-right">
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
{t("environments.integrations.notion.link_database")}
</Button>
</div>
@@ -48,7 +48,7 @@ const Loading = () => {
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
</div>
<div className="text-center"></div>

View File

@@ -15,6 +15,7 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { sendResponseFinishedEmail } from "@/modules/email";
@@ -135,13 +136,17 @@ export const POST = async (request: Request) => {
);
}
return fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
}).catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
});
return validateWebhookUrl(webhook.url)
.then(() =>
fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
})
)
.catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
});
});
if (event === "responseFinished") {

View File

@@ -6,140 +6,138 @@ export const GET = async (req: NextRequest) => {
let brandColor = req.nextUrl.searchParams.get("brandColor");
return new ImageResponse(
(
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
alignItems: "center",
backgroundColor: brandColor ? brandColor + "BF" : "#0000BFBF", // /75 opacity is approximately BF in hex
borderRadius: "0.75rem",
}}>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
alignItems: "center",
backgroundColor: brandColor ? brandColor + "BF" : "#0000BFBF", // /75 opacity is approximately BF in hex
width: "80%",
height: "60%",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "3.25rem",
position: "absolute",
left: "3rem",
top: "0.75rem",
opacity: 0.2,
transform: "rotate(356deg)",
}}></div>
<div
style={{
display: "flex",
flexDirection: "column",
width: "84%",
height: "60%",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "3rem",
position: "absolute",
top: "1.25rem",
left: "3.25rem",
borderWidth: "2px",
opacity: 0.6,
transform: "rotate(357deg)",
}}></div>
<div
style={{
display: "flex",
flexDirection: "column",
width: "85%",
height: "67%",
alignItems: "center",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "2rem",
position: "absolute",
top: "2.3rem",
left: "3.5rem",
transform: "rotate(360deg)",
}}>
<div
style={{
display: "flex",
flexDirection: "column",
width: "80%",
height: "60%",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "3.25rem",
position: "absolute",
left: "3rem",
top: "0.75rem",
opacity: 0.2,
transform: "rotate(356deg)",
}}></div>
<div
style={{
display: "flex",
flexDirection: "column",
width: "84%",
height: "60%",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "3rem",
position: "absolute",
top: "1.25rem",
left: "3.25rem",
borderWidth: "2px",
opacity: 0.6,
transform: "rotate(357deg)",
}}></div>
<div
style={{
display: "flex",
flexDirection: "column",
width: "85%",
height: "67%",
alignItems: "center",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "2rem",
position: "absolute",
top: "2.3rem",
left: "3.5rem",
transform: "rotate(360deg)",
}}>
<div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
<div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
justifyContent: "space-between",
}}>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
justifyContent: "space-between",
paddingLeft: "2rem",
paddingRight: "2rem",
}}>
<h2
style={{
display: "flex",
flexDirection: "column",
fontSize: "2rem",
fontWeight: "700",
letterSpacing: "-0.025em",
color: "#0f172a",
textAlign: "left",
marginTop: "3.75rem",
}}>
{name}
</h2>
</div>
</div>
<div style={{ display: "flex", justifyContent: "flex-end", marginRight: "2.5rem" }}>
<div
style={{
display: "flex",
borderRadius: "1rem",
position: "absolute",
right: "-0.5rem",
marginTop: "0.5rem",
}}>
<div
content=""
style={{
borderRadius: "0.75rem",
border: "1px solid transparent",
backgroundColor: brandColor ?? "#000",
height: "4.5rem",
width: "9.5rem",
opacity: 0.5,
}}></div>
</div>
<div
style={{
display: "flex",
borderRadius: "1rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
}}>
<div
style={{
display: "flex",
flexDirection: "column",
paddingLeft: "2rem",
paddingRight: "2rem",
alignItems: "center",
justifyContent: "center",
borderRadius: "0.75rem",
border: "1px solid transparent",
backgroundColor: brandColor ?? "#000",
fontSize: "1.5rem",
color: "white",
height: "4.5rem",
width: "9.5rem",
}}>
<h2
style={{
display: "flex",
flexDirection: "column",
fontSize: "2rem",
fontWeight: "700",
letterSpacing: "-0.025em",
color: "#0f172a",
textAlign: "left",
marginTop: "3.75rem",
}}>
{name}
</h2>
</div>
</div>
<div style={{ display: "flex", justifyContent: "flex-end", marginRight: "2.5rem" }}>
<div
style={{
display: "flex",
borderRadius: "1rem",
position: "absolute",
right: "-0.5rem",
marginTop: "0.5rem",
}}>
<div
content=""
style={{
borderRadius: "0.75rem",
border: "1px solid transparent",
backgroundColor: brandColor ?? "#000",
height: "4.5rem",
width: "9.5rem",
opacity: 0.5,
}}></div>
</div>
<div
style={{
display: "flex",
borderRadius: "1rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
}}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "0.75rem",
border: "1px solid transparent",
backgroundColor: brandColor ?? "#000",
fontSize: "1.5rem",
color: "white",
height: "4.5rem",
width: "9.5rem",
}}>
Begin!
</div>
Begin!
</div>
</div>
</div>
</div>
</div>
),
</div>,
{
width: 800,
height: 400,

View File

@@ -2,10 +2,11 @@ import { Prisma, WebhookSource } from "@prisma/client";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
import { createWebhook } from "@/app/api/v1/webhooks/lib/webhook";
import { TWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { validateInputs } from "@/lib/utils/validate";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -23,6 +24,10 @@ vi.mock("@/lib/crypto", () => ({
generateWebhookSecret: vi.fn(() => "whsec_test_secret_1234567890"),
}));
vi.mock("@/lib/utils/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn().mockResolvedValue(undefined),
}));
describe("createWebhook", () => {
afterEach(() => {
cleanup();
@@ -75,6 +80,41 @@ describe("createWebhook", () => {
expect(result).toEqual(createdWebhook);
});
test("should call validateWebhookUrl with the provided URL", async () => {
const webhookInput: TWebhookInput = {
environmentId: "test-env-id",
name: "Test Webhook",
url: "https://example.com",
source: "user",
triggers: ["responseCreated"],
surveyIds: ["survey1"],
};
vi.mocked(prisma.webhook.create).mockResolvedValueOnce({} as any);
await createWebhook(webhookInput);
expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com");
});
test("should throw InvalidInputError and skip Prisma create when URL fails SSRF validation", async () => {
const webhookInput: TWebhookInput = {
environmentId: "test-env-id",
name: "Test Webhook",
url: "http://169.254.169.254/latest/meta-data/",
source: "user",
triggers: ["responseCreated"],
surveyIds: ["survey1"],
};
vi.mocked(validateWebhookUrl).mockRejectedValueOnce(
new InvalidInputError("Webhook URL must not point to private or internal IP addresses")
);
await expect(createWebhook(webhookInput)).rejects.toThrow(InvalidInputError);
expect(prisma.webhook.create).not.toHaveBeenCalled();
});
test("should throw a ValidationError if the input data does not match the ZWebhookInput schema", async () => {
const invalidWebhookInput = {
environmentId: "test-env-id",

View File

@@ -6,9 +6,11 @@ import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhoo
import { ITEMS_PER_PAGE } from "@/lib/constants";
import { generateWebhookSecret } from "@/lib/crypto";
import { validateInputs } from "@/lib/utils/validate";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
validateInputs([webhookInput, ZWebhookInput]);
await validateWebhookUrl(webhookInput.url);
try {
const secret = generateWebhookSecret();

View File

@@ -131,13 +131,11 @@ describe("withV1ApiWrapper", () => {
});
test("logs and audits on error response with API key authentication", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -185,13 +183,11 @@ describe("withV1ApiWrapper", () => {
});
test("does not log Sentry if not 500", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -233,13 +229,11 @@ describe("withV1ApiWrapper", () => {
});
test("logs and audits on thrown error", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -291,13 +285,11 @@ describe("withV1ApiWrapper", () => {
});
test("does not log on success response but still audits", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -347,13 +339,11 @@ describe("withV1ApiWrapper", () => {
REDIS_URL: "redis://localhost:6379",
}));
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { withV1ApiWrapper } = await import("./with-api-logging");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
@@ -376,9 +366,8 @@ describe("withV1ApiWrapper", () => {
});
test("handles client-side API routes without authentication", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
@@ -410,9 +399,8 @@ describe("withV1ApiWrapper", () => {
});
test("returns authentication error for non-client routes without auth", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -435,9 +423,8 @@ describe("withV1ApiWrapper", () => {
test("handles rate limiting errors", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
@@ -462,13 +449,11 @@ describe("withV1ApiWrapper", () => {
});
test("skips audit log creation when no action/targetType provided", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });

View File

@@ -313,7 +313,7 @@ describe("endpoint-validator", () => {
expect(isPublicDomainRoute("/c")).toBe(false);
expect(isPublicDomainRoute("/contact/token")).toBe(false);
});
test("should return true for pretty URL survey routes", () => {
expect(isPublicDomainRoute("/p/pretty123")).toBe(true);
expect(isPublicDomainRoute("/p/pretty-name-with-dashes")).toBe(true);

View File

@@ -0,0 +1,278 @@
import dns from "node:dns";
import { afterEach, describe, expect, test, vi } from "vitest";
import { validateWebhookUrl } from "./validate-webhook-url";
vi.mock("node:dns", () => ({
default: {
resolve: vi.fn(),
resolve6: vi.fn(),
},
}));
const mockResolve = vi.mocked(dns.resolve);
const mockResolve6 = vi.mocked(dns.resolve6);
type DnsCallback = (err: NodeJS.ErrnoException | null, addresses: string[]) => void;
const setupDnsResolution = (ipv4: string[] | null, ipv6: string[] | null = null): void => {
// dns.resolve/resolve6 have overloaded signatures; we only mock the (hostname, callback) form
mockResolve.mockImplementation(((_hostname: string, callback: DnsCallback) => {
if (ipv4) {
callback(null, ipv4);
} else {
callback(new Error("ENOTFOUND"), []);
}
}) as never);
mockResolve6.mockImplementation(((_hostname: string, callback: DnsCallback) => {
if (ipv6) {
callback(null, ipv6);
} else {
callback(new Error("ENOTFOUND"), []);
}
}) as never);
};
afterEach(() => {
vi.clearAllMocks();
});
describe("validateWebhookUrl", () => {
describe("valid public URLs", () => {
test("accepts HTTPS URL resolving to a public IPv4 address", async () => {
setupDnsResolution(["93.184.216.34"]);
await expect(validateWebhookUrl("https://example.com/webhook")).resolves.toBeUndefined();
});
test("accepts HTTP URL resolving to a public IPv4 address", async () => {
setupDnsResolution(["93.184.216.34"]);
await expect(validateWebhookUrl("http://example.com/webhook")).resolves.toBeUndefined();
});
test("accepts URL with port and path segments", async () => {
setupDnsResolution(["93.184.216.34"]);
await expect(validateWebhookUrl("https://example.com:8443/api/v1/webhook")).resolves.toBeUndefined();
});
test("accepts URL resolving to a public IPv6 address", async () => {
setupDnsResolution(null, ["2606:2800:220:1:248:1893:25c8:1946"]);
await expect(validateWebhookUrl("https://example.com/webhook")).resolves.toBeUndefined();
});
test("accepts a public IPv4 address as hostname", async () => {
await expect(validateWebhookUrl("https://93.184.216.34/webhook")).resolves.toBeUndefined();
});
});
describe("URL format validation", () => {
test("rejects a completely malformed string", async () => {
await expect(validateWebhookUrl("not-a-url")).rejects.toThrow("Invalid webhook URL format");
});
test("rejects an empty string", async () => {
await expect(validateWebhookUrl("")).rejects.toThrow("Invalid webhook URL format");
});
});
describe("protocol validation", () => {
test("rejects FTP protocol", async () => {
await expect(validateWebhookUrl("ftp://example.com/file")).rejects.toThrow(
"Webhook URL must use HTTPS or HTTP protocol"
);
});
test("rejects file:// protocol", async () => {
await expect(validateWebhookUrl("file:///etc/passwd")).rejects.toThrow(
"Webhook URL must use HTTPS or HTTP protocol"
);
});
test("rejects javascript: protocol", async () => {
await expect(validateWebhookUrl("javascript:alert(1)")).rejects.toThrow(
"Webhook URL must use HTTPS or HTTP protocol"
);
});
});
describe("blocked hostname validation", () => {
test("rejects localhost", async () => {
await expect(validateWebhookUrl("http://localhost/admin")).rejects.toThrow(
"Webhook URL must not point to localhost or internal services"
);
});
test("rejects localhost.localdomain", async () => {
await expect(validateWebhookUrl("https://localhost.localdomain/path")).rejects.toThrow(
"Webhook URL must not point to localhost or internal services"
);
});
test("rejects metadata.google.internal", async () => {
await expect(validateWebhookUrl("http://metadata.google.internal/computeMetadata/v1/")).rejects.toThrow(
"Webhook URL must not point to localhost or internal services"
);
});
});
describe("private IPv4 literal blocking", () => {
test("rejects 127.0.0.1 (loopback)", async () => {
await expect(validateWebhookUrl("http://127.0.0.1/metadata")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 127.0.0.53 (loopback range)", async () => {
await expect(validateWebhookUrl("http://127.0.0.53/")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 10.0.0.1 (Class A private)", async () => {
await expect(validateWebhookUrl("http://10.0.0.1/internal")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 172.16.0.1 (Class B private)", async () => {
await expect(validateWebhookUrl("http://172.16.0.1/internal")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 172.31.255.255 (Class B private upper bound)", async () => {
await expect(validateWebhookUrl("http://172.31.255.255/internal")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 192.168.1.1 (Class C private)", async () => {
await expect(validateWebhookUrl("http://192.168.1.1/internal")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 169.254.169.254 (AWS/GCP/Azure metadata endpoint)", async () => {
await expect(validateWebhookUrl("http://169.254.169.254/latest/meta-data/")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 0.0.0.0 ('this' network)", async () => {
await expect(validateWebhookUrl("http://0.0.0.0/")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 100.64.0.1 (CGNAT / shared address space)", async () => {
await expect(validateWebhookUrl("http://100.64.0.1/")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
});
describe("DNS resolution with private IP results", () => {
test("rejects hostname resolving to loopback address", async () => {
setupDnsResolution(["127.0.0.1"]);
await expect(validateWebhookUrl("https://evil.com/steal")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to cloud metadata endpoint IP", async () => {
setupDnsResolution(["169.254.169.254"]);
await expect(validateWebhookUrl("https://attacker.com/ssrf")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to Class A private network", async () => {
setupDnsResolution(["10.0.0.5"]);
await expect(validateWebhookUrl("https://internal.service/api")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to Class C private network", async () => {
setupDnsResolution(["192.168.0.1"]);
await expect(validateWebhookUrl("https://sneaky.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to IPv6 loopback", async () => {
setupDnsResolution(null, ["::1"]);
await expect(validateWebhookUrl("https://sneaky.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to IPv6 link-local", async () => {
setupDnsResolution(null, ["fe80::1"]);
await expect(validateWebhookUrl("https://link-local.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to IPv6 unique local address", async () => {
setupDnsResolution(null, ["fd12:3456:789a::1"]);
await expect(validateWebhookUrl("https://ula.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to IPv4-mapped IPv6 private address (dotted)", async () => {
setupDnsResolution(null, ["::ffff:192.168.1.1"]);
await expect(validateWebhookUrl("https://mapped.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to IPv4-mapped IPv6 private address (hex-encoded)", async () => {
setupDnsResolution(null, ["::ffff:c0a8:0101"]); // 192.168.1.1 in hex
await expect(validateWebhookUrl("https://hex-mapped.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hex-encoded IPv4-mapped loopback (::ffff:7f00:0001)", async () => {
setupDnsResolution(null, ["::ffff:7f00:0001"]); // 127.0.0.1 in hex
await expect(validateWebhookUrl("https://hex-loopback.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hex-encoded IPv4-mapped metadata endpoint (::ffff:a9fe:a9fe)", async () => {
setupDnsResolution(null, ["::ffff:a9fe:a9fe"]); // 169.254.169.254 in hex
await expect(validateWebhookUrl("https://hex-metadata.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("accepts hex-encoded IPv4-mapped public address", async () => {
setupDnsResolution(null, ["::ffff:5db8:d822"]); // 93.184.216.34 in hex
await expect(validateWebhookUrl("https://hex-public.example.com/webhook")).resolves.toBeUndefined();
});
test("rejects when any resolved IP is private (mixed public + private)", async () => {
setupDnsResolution(["93.184.216.34", "192.168.1.1"]);
await expect(validateWebhookUrl("https://dual.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects unresolvable hostname", async () => {
setupDnsResolution(null, null);
await expect(validateWebhookUrl("https://nonexistent.invalid/path")).rejects.toThrow(
"Could not resolve webhook URL hostname"
);
});
});
describe("error type", () => {
test("throws InvalidInputError (not generic Error)", async () => {
await expect(validateWebhookUrl("http://127.0.0.1/")).rejects.toMatchObject({
name: "InvalidInputError",
});
});
});
});

View File

@@ -0,0 +1,156 @@
import "server-only";
import dns from "node:dns";
import { InvalidInputError } from "@formbricks/types/errors";
const BLOCKED_HOSTNAMES = new Set([
"localhost",
"localhost.localdomain",
"ip6-localhost",
"ip6-loopback",
"metadata.google.internal",
]);
const PRIVATE_IPV4_PATTERNS: RegExp[] = [
/^127\./, // 127.0.0.0/8 Loopback
/^10\./, // 10.0.0.0/8 Class A private
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 Class B private
/^192\.168\./, // 192.168.0.0/16 Class C private
/^169\.254\./, // 169.254.0.0/16 Link-local (AWS/GCP/Azure metadata)
/^0\./, // 0.0.0.0/8 "This" network
/^100\.(6[4-9]|[7-9]\d|1[0-2]\d)\./, // 100.64.0.0/10 Shared address space (RFC 6598)
/^192\.0\.0\./, // 192.0.0.0/24 IETF protocol assignments
/^192\.0\.2\./, // 192.0.2.0/24 TEST-NET-1 (documentation)
/^198\.51\.100\./, // 198.51.100.0/24 TEST-NET-2 (documentation)
/^203\.0\.113\./, // 203.0.113.0/24 TEST-NET-3 (documentation)
/^198\.1[89]\./, // 198.18.0.0/15 Benchmarking
/^224\./, // 224.0.0.0/4 Multicast
/^240\./, // 240.0.0.0/4 Reserved for future use
/^255\.255\.255\.255$/, // Limited broadcast
];
const PRIVATE_IPV6_PREFIXES = [
"::1", // Loopback
"fe80:", // Link-local
"fc", // Unique local address (ULA, fc00::/7 — covers fc00:: through fdff::)
"fd", // Unique local address (ULA, fc00::/7 — covers fc00:: through fdff::)
];
const isPrivateIPv4 = (ip: string): boolean => {
return PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(ip));
};
const hexMappedToIPv4 = (hexPart: string): string | null => {
const groups = hexPart.split(":");
if (groups.length !== 2) return null;
const high = Number.parseInt(groups[0], 16);
const low = Number.parseInt(groups[1], 16);
if (Number.isNaN(high) || Number.isNaN(low) || high > 0xffff || low > 0xffff) return null;
return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
};
const isIPv4Mapped = (normalized: string): boolean => {
if (!normalized.startsWith("::ffff:")) return false;
const suffix = normalized.slice(7); // strip "::ffff:"
if (suffix.includes(".")) {
return isPrivateIPv4(suffix);
}
const dotted = hexMappedToIPv4(suffix);
return dotted !== null && isPrivateIPv4(dotted);
};
const isPrivateIPv6 = (ip: string): boolean => {
const normalized = ip.toLowerCase();
if (normalized === "::") return true;
if (isIPv4Mapped(normalized)) return true;
return PRIVATE_IPV6_PREFIXES.some((prefix) => normalized.startsWith(prefix));
};
const isPrivateIP = (ip: string): boolean => {
return isPrivateIPv4(ip) || isPrivateIPv6(ip);
};
const resolveHostnameToIPs = (hostname: string): Promise<string[]> => {
return new Promise((resolve, reject) => {
dns.resolve(hostname, (errV4, ipv4Addresses) => {
const ipv4 = errV4 ? [] : ipv4Addresses;
dns.resolve6(hostname, (errV6, ipv6Addresses) => {
const ipv6 = errV6 ? [] : ipv6Addresses;
const allAddresses = [...ipv4, ...ipv6];
if (allAddresses.length === 0) {
reject(new Error(`DNS resolution failed for hostname: ${hostname}`));
} else {
resolve(allAddresses);
}
});
});
});
};
const stripIPv6Brackets = (hostname: string): string => {
if (hostname.startsWith("[") && hostname.endsWith("]")) {
return hostname.slice(1, -1);
}
return hostname;
};
const IPV4_LITERAL = /^\d{1,3}(?:\.\d{1,3}){3}$/;
/**
* Validates a webhook URL to prevent Server-Side Request Forgery (SSRF).
*
* Checks performed:
* 1. URL must be well-formed
* 2. Protocol must be HTTPS or HTTP
* 3. Hostname must not be a known internal name (localhost, metadata endpoints)
* 4. IP literal hostnames are checked directly against private ranges
* 5. Domain hostnames are resolved via DNS; all resulting IPs must be public
*
* @throws {InvalidInputError} when the URL fails any validation check
*/
export const validateWebhookUrl = async (url: string): Promise<void> => {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new InvalidInputError("Invalid webhook URL format");
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
throw new InvalidInputError("Webhook URL must use HTTPS or HTTP protocol");
}
const hostname = parsed.hostname;
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
}
// Direct IP literal — validate without DNS resolution
const isIPv4Literal = IPV4_LITERAL.test(hostname);
const isIPv6Literal = hostname.startsWith("[");
if (isIPv4Literal || isIPv6Literal) {
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
return;
}
// Domain name — resolve DNS and validate every resolved IP
let resolvedIPs: string[];
try {
resolvedIPs = await resolveHostnameToIPs(hostname);
} catch {
throw new InvalidInputError(`Could not resolve webhook URL hostname: ${hostname}`);
}
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
}
};

View File

@@ -42,7 +42,7 @@ export const SingleResponseCardBody = ({
return (
<span
key={index}
className="mr-0.5 ml-0.5 rounded-md border border-slate-200 bg-slate-50 px-1 py-0.5 text-sm first:ml-0">
className="ml-0.5 mr-0.5 rounded-md border border-slate-200 bg-slate-50 px-1 py-0.5 text-sm first:ml-0">
@{part}
</span>
);

View File

@@ -1,6 +1,8 @@
import { describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { InvalidInputError } from "@formbricks/types/errors";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import {
mockedPrismaWebhookUpdateReturn,
prismaNotFoundError,
@@ -18,6 +20,10 @@ vi.mock("@formbricks/database", () => ({
},
}));
vi.mock("@/lib/utils/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn().mockResolvedValue(undefined),
}));
describe("getWebhook", () => {
test("returns ok if webhook is found", async () => {
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce({ id: "123" });
@@ -63,6 +69,30 @@ describe("updateWebhook", () => {
}
});
test("calls validateWebhookUrl when URL is provided", async () => {
vi.mocked(prisma.webhook.update).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn);
await updateWebhook("123", mockedWebhookUpdateReturn);
expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com");
});
test("returns bad_request and skips Prisma update when URL fails SSRF validation", async () => {
vi.mocked(validateWebhookUrl).mockRejectedValueOnce(
new InvalidInputError("Webhook URL must not point to private or internal IP addresses")
);
const result = await updateWebhook("123", mockedWebhookUpdateReturn);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("bad_request");
expect(result.error.details[0].field).toBe("url");
}
expect(prisma.webhook.update).not.toHaveBeenCalled();
});
test("returns not_found if record does not exist", async () => {
vi.mocked(prisma.webhook.update).mockRejectedValueOnce(prismaNotFoundError);
const result = await updateWebhook("999", mockedWebhookUpdateReturn);

View File

@@ -3,6 +3,8 @@ import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { InvalidInputError } from "@formbricks/types/errors";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { ZWebhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
@@ -34,6 +36,22 @@ export const updateWebhook = async (
webhookId: string,
webhookInput: z.infer<typeof ZWebhookUpdateSchema>
): Promise<Result<Webhook, ApiErrorResponseV2>> => {
if (webhookInput.url) {
try {
await validateWebhookUrl(webhookInput.url);
} catch (error) {
return err({
type: "bad_request",
details: [
{
field: "url",
issue: error instanceof InvalidInputError ? error.message : "Invalid webhook URL",
},
],
});
}
}
try {
const updatedWebhook = await prisma.webhook.update({
where: {

View File

@@ -1,6 +1,8 @@
import { WebhookSource } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError } from "@formbricks/types/errors";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { createWebhook, getWebhooks } from "../webhook";
@@ -15,6 +17,10 @@ vi.mock("@formbricks/database", () => ({
},
}));
vi.mock("@/lib/utils/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn().mockResolvedValue(undefined),
}));
describe("getWebhooks", () => {
const environmentId = "env1";
const params = {
@@ -89,6 +95,30 @@ describe("createWebhook", () => {
}
});
test("calls validateWebhookUrl with the provided URL", async () => {
vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook);
await createWebhook(inputWebhook);
expect(validateWebhookUrl).toHaveBeenCalledWith("http://example.com");
});
test("returns bad_request and skips Prisma create when URL fails SSRF validation", async () => {
vi.mocked(validateWebhookUrl).mockRejectedValueOnce(
new InvalidInputError("Webhook URL must not point to private or internal IP addresses")
);
const result = await createWebhook(inputWebhook);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toEqual("bad_request");
expect(result.error.details[0].field).toEqual("url");
}
expect(prisma.webhook.create).not.toHaveBeenCalled();
});
test("returns error when creation fails", async () => {
vi.mocked(prisma.webhook.create).mockRejectedValueOnce(new Error("Creation failed"));

View File

@@ -1,7 +1,9 @@
import { Prisma, Webhook } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { InvalidInputError } from "@formbricks/types/errors";
import { generateWebhookSecret } from "@/lib/crypto";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils";
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
@@ -49,6 +51,20 @@ export const getWebhooks = async (
export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webhook, ApiErrorResponseV2>> => {
const { environmentId, name, url, source, triggers, surveyIds } = webhook;
try {
await validateWebhookUrl(url);
} catch (error) {
return err({
type: "bad_request",
details: [
{
field: "url",
issue: error instanceof InvalidInputError ? error.message : "Invalid webhook URL",
},
],
});
}
try {
const secret = generateWebhookSecret();

View File

@@ -15,7 +15,7 @@ export const EmailChangeWithoutVerificationSuccessPage = async () => {
}
return (
<div className="bg-gradient-radial flex min-h-screen from-slate-200 to-slate-50">
<div className="flex min-h-screen bg-gradient-radial from-slate-200 to-slate-50">
<FormWrapper>
<h1 className="leading-2 mb-4 text-center font-bold">
{t("auth.email-change.email_change_success")}

View File

@@ -60,7 +60,7 @@ export const ForgotPasswordForm = () => {
onChange={(e) => field.onChange(e)}
autoComplete="email"
required
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
/>
</FormControl>
{error?.message && <FormError className="text-left">{error.message}</FormError>}

View File

@@ -1,8 +1,10 @@
import { Invite } from "@prisma/client";
import { TUserLocale } from "@formbricks/types/user";
export interface InviteWithCreator
extends Pick<Invite, "id" | "expiresAt" | "organizationId" | "role" | "teamIds"> {
export interface InviteWithCreator extends Pick<
Invite,
"id" | "expiresAt" | "organizationId" | "role" | "teamIds"
> {
creator: {
name: string | null;
email: string;

View File

@@ -24,7 +24,7 @@ export const AuthLayout = async ({ children }: { children: React.ReactNode }) =>
<Toaster />
<div className="min-h-screen bg-slate-50">
<div className="isolate bg-white">
<div className="bg-gradient-radial flex min-h-screen from-slate-200 to-slate-50">{children}</div>
<div className="flex min-h-screen bg-gradient-radial from-slate-200 to-slate-50">{children}</div>
</div>
</div>
</>

View File

@@ -184,7 +184,7 @@ export const LoginForm = ({
value={field.value}
onChange={(email) => field.onChange(email)}
placeholder="work@email.com"
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
@@ -207,7 +207,7 @@ export const LoginForm = ({
aria-label="password"
aria-required="true"
required
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 pr-8 shadow-sm sm:text-sm"
className="block w-full rounded-md border-slate-300 pr-8 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
value={field.value}
onChange={(password) => field.onChange(password)}
/>
@@ -221,7 +221,7 @@ export const LoginForm = ({
<div className="ml-1 text-right transition-all duration-500 ease-in-out">
<Link
href="/auth/forgot-password"
className="hover:text-brand-dark text-xs text-slate-500">
className="text-xs text-slate-500 hover:text-brand-dark">
{t("auth.login.forgot_your_password")}
</Link>
</div>

View File

@@ -222,7 +222,7 @@ export const SignupForm = ({
placeholder="*******"
aria-placeholder="password"
required
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md shadow-sm sm:text-sm"
className="block w-full rounded-md shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>

View File

@@ -6,7 +6,7 @@ export const VerifyEmailChangePage = async ({ searchParams }) => {
const { token } = await searchParams;
return (
<div className="bg-gradient-radial flex min-h-screen from-slate-200 to-slate-50">
<div className="flex min-h-screen bg-gradient-radial from-slate-200 to-slate-50">
<FormWrapper>
<EmailChangeSignIn token={token} />
<BackToLoginButton />

View File

@@ -138,7 +138,7 @@ export const PricingTable = ({
<div className="flex flex-col gap-8">
<div className="flex flex-col">
<div className="flex w-full">
<h2 className="mr-2 mb-3 inline-flex w-full text-2xl font-bold text-slate-700">
<h2 className="mb-3 mr-2 inline-flex w-full text-2xl font-bold text-slate-700">
{t("environments.settings.billing.current_plan")}:{" "}
<span className="capitalize">{organization.billing.plan}</span>
{cancellingOn && (
@@ -203,7 +203,7 @@ export const PricingTable = ({
<div
className={cn(
"relative mx-8 mb-8 flex flex-col gap-4",
peopleUnlimitedCheck && "mt-4 mb-0 flex-row pb-0"
peopleUnlimitedCheck && "mb-0 mt-4 flex-row pb-0"
)}>
<p className="text-md font-semibold text-slate-700">
{t("environments.settings.billing.monthly_identified_users")}
@@ -226,7 +226,7 @@ export const PricingTable = ({
<div
className={cn(
"relative mx-8 flex flex-col gap-4 pb-6",
projectsUnlimitedCheck && "mt-4 mb-0 flex-row pb-0"
projectsUnlimitedCheck && "mb-0 mt-4 flex-row pb-0"
)}>
<p className="text-md font-semibold text-slate-700">{t("common.workspaces")}</p>
{organization.billing.limits.projects && (
@@ -264,7 +264,7 @@ export const PricingTable = ({
</button>
<button
aria-pressed={planPeriod === "yearly"}
className={`flex-1 items-center rounded-md py-0.5 pr-2 pl-4 text-center whitespace-nowrap ${
className={`flex-1 items-center whitespace-nowrap rounded-md py-0.5 pl-4 pr-2 text-center ${
planPeriod === "yearly" ? "bg-slate-200 font-semibold" : "bg-transparent"
}`}
onClick={() => handleMonthlyToggle("yearly")}>
@@ -276,7 +276,7 @@ export const PricingTable = ({
</div>
<div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-3">
<div
className="hidden lg:absolute lg:inset-x-px lg:top-4 lg:bottom-0 lg:block lg:rounded-xl lg:rounded-t-2xl lg:border lg:border-slate-200 lg:bg-slate-100 lg:pb-8 lg:ring-1 lg:ring-white/10"
className="hidden lg:absolute lg:inset-x-px lg:bottom-0 lg:top-4 lg:block lg:rounded-xl lg:rounded-t-2xl lg:border lg:border-slate-200 lg:bg-slate-100 lg:pb-8 lg:ring-1 lg:ring-white/10"
aria-hidden="true"
/>
{getCloudPricingData(t).plans.map((plan) => (

View File

@@ -98,7 +98,7 @@ export const ActivityTimeline = ({
<button
type="button"
onClick={toggleSort}
className="hover:text-brand-dark flex items-center px-1 text-slate-800">
className="flex items-center px-1 text-slate-800 hover:text-brand-dark">
<ArrowDownUpIcon className="inline h-4 w-4" />
</button>
</div>

View File

@@ -46,7 +46,7 @@ export const generateAttributeTableColumns = (
cell: ({ row }) => {
const description = row.original.description;
return description ? (
<div className={isExpanded ? "break-words whitespace-normal" : "truncate"}>
<div className={isExpanded ? "whitespace-normal break-words" : "truncate"}>
<HighlightedText value={description} searchValue={searchValue} />
</div>
) : (

View File

@@ -132,7 +132,7 @@ export const UploadContactsAttributes = ({
return (
<>
<span className="overflow-hidden font-medium text-ellipsis text-slate-700">{csvColumn}</span>
<span className="overflow-hidden text-ellipsis font-medium text-slate-700">{csvColumn}</span>
<div className="flex items-center gap-2">
<UploadContactsAttributeCombobox
open={open}

View File

@@ -93,7 +93,7 @@ export const EditSegmentModal = ({
key={tab.title}
className={`mr-4 px-1 pb-3 focus:outline-none ${
activeTab === index
? "border-brand-dark border-b-2 font-semibold text-slate-900"
? "border-b-2 border-brand-dark font-semibold text-slate-900"
: "text-slate-500 hover:text-slate-700"
}`}
onClick={() => handleTabClick(index)}>

View File

@@ -42,7 +42,7 @@ export const SegmentTableDataRow = ({
</div>
</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-nowrap text-slate-500 sm:block">
<div className="col-span-1 my-auto hidden whitespace-nowrap text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{surveys?.length}</div>
</div>
<div className="whitespace-wrap col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
@@ -52,7 +52,7 @@ export const SegmentTableDataRow = ({
}).replace("about", "")}
</div>
</div>
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-normal text-slate-500 sm:block">
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
<div className="ph-no-capture text-slate-900">{format(createdAt, "do 'of' MMMM, yyyy")}</div>
</div>
</button>

View File

@@ -176,7 +176,7 @@ export function TargetingCard({
asChild
className="h-full w-full cursor-pointer rounded-lg hover:bg-slate-50">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pr-5 pl-2">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
strokeWidth={3}

View File

@@ -249,7 +249,7 @@ export function EditLanguage({
))}
</>
) : (
<p className="text-sm text-slate-500 italic">
<p className="text-sm italic text-slate-500">
{t("environments.workspace.languages.no_language_found")}
</p>
)}

View File

@@ -188,7 +188,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
<div
className={cn(
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none"
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
)}>
<p>
<Languages className="h-6 w-6 rounded-full bg-indigo-500 p-1 text-white" />
@@ -251,7 +251,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
) : (
<>
{projectLanguages.length <= 1 && (
<div className="mb-4 text-sm text-slate-500 italic">
<div className="mb-4 text-sm italic text-slate-500">
{projectLanguages.length === 0
? t("environments.surveys.edit.no_languages_found_add_first_one_to_get_started")
: t(
@@ -262,7 +262,7 @@ export const MultiLanguageCard: FC<MultiLanguageCardProps> = ({
{projectLanguages.length > 1 && (
<div className="space-y-6">
{isMultiLanguageAllowed && !isMultiLanguageActivated ? (
<div className="text-sm text-slate-500 italic">
<div className="text-sm italic text-slate-500">
{t("environments.surveys.edit.switch_multi_language_on_to_get_started")}
</div>
) : null}

View File

@@ -77,7 +77,7 @@ export const ConfirmPasswordForm = ({
required
onChange={(password) => field.onChange(password)}
value={field.value}
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem>

View File

@@ -109,7 +109,7 @@ export const DisableTwoFactorModal = ({ open, setOpen }: DisableTwoFactorModalPr
required
onChange={(password) => field.onChange(password)}
value={field.value}
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</FormItem>

View File

@@ -35,7 +35,7 @@ export const TwoFactorBackup = ({ form }: TwoFactorBackupProps) => {
id="totpBackup"
required
placeholder="XXXXX-XXXXX"
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
/>

View File

@@ -206,7 +206,7 @@ export const EmailCustomizationSettings = ({
<div className="mb-10">
<Small>{t("environments.settings.general.logo_in_email_header")}</Small>
<div className="mt-2 mb-6 flex items-center gap-4">
<div className="mb-6 mt-2 flex items-center gap-4">
{logoUrl && (
<div className="flex flex-col gap-2">
<div className="flex w-max items-center justify-center rounded-lg border border-slate-200 px-4 py-2">
@@ -276,7 +276,7 @@ export const EmailCustomizationSettings = ({
</Button>
</div>
</div>
<div className="shadow-card-xl min-h-52 w-[446px] rounded-t-lg border border-slate-100 px-10 pt-10 pb-4">
<div className="min-h-52 w-[446px] rounded-t-lg border border-slate-100 px-10 pb-4 pt-10 shadow-card-xl">
<Image
data-testid="email-customization-preview-image"
src={logoUrl || fbLogoUrl}
@@ -304,7 +304,7 @@ export const EmailCustomizationSettings = ({
)}
{hasWhiteLabelPermission && isReadOnly && (
<Alert variant="warning" className="mt-4 mb-6">
<Alert variant="warning" className="mb-6 mt-4">
<AlertDescription>
{t("common.only_owners_managers_and_manage_access_members_can_perform_this_action")}
</AlertDescription>

View File

@@ -70,7 +70,7 @@ export const WebhookModal = ({ open, setOpen, webhook, surveys, isReadOnly }: We
type="button"
className={`mr-4 px-1 pb-3 focus:outline-none ${
activeTab === index
? "border-brand-dark border-b-2 font-semibold text-slate-900"
? "border-b-2 border-brand-dark font-semibold text-slate-900"
: "text-slate-500 hover:text-slate-700"
}`}
onClick={() => handleTabClick(index)}>

View File

@@ -11,6 +11,7 @@ import {
} from "@formbricks/types/errors";
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
import { validateInputs } from "@/lib/utils/validate";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
import { TWebhookInput } from "../types/webhooks";
@@ -18,6 +19,10 @@ export const updateWebhook = async (
webhookId: string,
webhookInput: Partial<TWebhookInput>
): Promise<boolean> => {
if (webhookInput.url) {
await validateWebhookUrl(webhookInput.url);
}
try {
await prisma.webhook.update({
where: {
@@ -66,6 +71,8 @@ export const createWebhook = async (
webhookInput: TWebhookInput,
secret?: string
): Promise<Webhook> => {
await validateWebhookUrl(webhookInput.url);
try {
if (isDiscordWebhook(webhookInput.url)) {
throw new UnknownError("Discord webhooks are currently not supported.");
@@ -123,6 +130,8 @@ export const getWebhooks = async (environmentId: string): Promise<Webhook[]> =>
};
export const testEndpoint = async (url: string, secret?: string): Promise<boolean> => {
await validateWebhookUrl(url);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);

View File

@@ -134,7 +134,7 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje
return (
<div className="flex items-center justify-between gap-2">
<span className="break-all whitespace-pre-line">{apiKey}</span>
<span className="whitespace-pre-line break-all">{apiKey}</span>
<div className="copyApiKeyIcon flex-shrink-0">
<FilesIcon
className="h-4 w-4 cursor-pointer"
@@ -162,7 +162,7 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje
</div>
<div className="grid-cols-9">
{apiKeysLocal?.length === 0 ? (
<div className="flex h-12 items-center justify-center px-6 text-sm font-medium whitespace-nowrap text-slate-400">
<div className="flex h-12 items-center justify-center whitespace-nowrap px-6 text-sm font-medium text-slate-400">
{t("environments.workspace.api_keys.no_api_keys_yet")}
</div>
) : (

View File

@@ -10,7 +10,7 @@ const LoadingCard = () => {
return (
<div className="w-full max-w-4xl rounded-xl border border-slate-200 bg-white py-4 shadow-sm">
<div className="grid content-center border-b border-slate-200 px-4 pb-4 text-left text-slate-900">
<h3 className="h-6 w-full max-w-56 animate-pulse rounded-lg bg-slate-100 text-lg leading-6 font-medium">
<h3 className="h-6 w-full max-w-56 animate-pulse rounded-lg bg-slate-100 text-lg font-medium leading-6">
<span className="sr-only">{t("common.loading")}</span>
</h3>
<p className="mt-3 h-4 w-full max-w-80 animate-pulse rounded-lg bg-slate-100 text-sm text-slate-500">

View File

@@ -50,8 +50,10 @@ export const TApiKeyEnvironmentPermission = z.object({
export type TApiKeyEnvironmentPermission = z.infer<typeof TApiKeyEnvironmentPermission>;
export interface TApiKeyWithEnvironmentPermission
extends Pick<ApiKey, "id" | "label" | "createdAt" | "organizationAccess"> {
export interface TApiKeyWithEnvironmentPermission extends Pick<
ApiKey,
"id" | "label" | "createdAt" | "organizationAccess"
> {
apiKeyEnvironments: TApiKeyEnvironmentPermission[];
}

View File

@@ -3,8 +3,10 @@ import { z } from "zod";
import { ZInvite } from "@formbricks/database/zod/invites";
import { ZUserName } from "@formbricks/types/user";
export interface TInvite
extends Omit<Invite, "deprecatedRole" | "organizationId" | "creatorId" | "acceptorId" | "teamIds"> {}
export interface TInvite extends Omit<
Invite,
"deprecatedRole" | "organizationId" | "creatorId" | "acceptorId" | "teamIds"
> {}
export interface InviteWithCreator extends Pick<Invite, "email"> {
creator: {

View File

@@ -151,7 +151,7 @@ export const ActionActivityTab = ({
<Label className="block text-xs font-normal text-slate-500">Type</Label>
<div className="mt-1 flex items-center">
<div className="mr-1.5 h-4 w-4 text-slate-600">{ACTION_TYPE_ICON_LOOKUP[actionClass.type]}</div>
<p className="text-sm text-slate-700 capitalize">{actionClass.type}</p>
<p className="text-sm capitalize text-slate-700">{actionClass.type}</p>
</div>
</div>
<div className="">

View File

@@ -90,7 +90,7 @@ export const CustomScriptsForm: React.FC<CustomScriptsFormProps> = ({ project, i
rows={8}
placeholder={t("environments.workspace.general.custom_scripts_placeholder")}
className={cn(
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50",
"flex w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
isReadOnly && "bg-slate-50"
)}
{...field}

View File

@@ -203,7 +203,9 @@ export const ThemeStyling = ({
</FormDescription>
<FormControl>
<ColorPicker
color={field.value ?? STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor}
color={
field.value ?? STYLE_DEFAULTS.brandColor?.light ?? COLOR_DEFAULTS.brandColor
}
onChange={(color) => field.onChange(color)}
containerClass="w-full"
/>

View File

@@ -34,7 +34,7 @@ export const MergeTagsCombobox = ({ tags, onSelect }: MergeTagsComboboxProps) =>
<Button
variant="secondary"
size="sm"
className="font-medium text-slate-900 focus:border-transparent focus:ring-0 focus:shadow-transparent focus:ring-transparent focus:outline-transparent">
className="font-medium text-slate-900 focus:border-transparent focus:shadow-transparent focus:outline-transparent focus:ring-0 focus:ring-transparent">
{t("environments.workspace.tags.merge")}
</Button>
</PopoverTrigger>
@@ -43,7 +43,7 @@ export const MergeTagsCombobox = ({ tags, onSelect }: MergeTagsComboboxProps) =>
<div className="p-1">
<CommandInput
placeholder={t("environments.workspace.tags.search_tags")}
className="border-b border-none border-transparent shadow-none ring-offset-transparent outline-0 focus:border-none focus:border-transparent focus:shadow-none focus:ring-offset-transparent focus:outline-0"
className="border-b border-none border-transparent shadow-none outline-0 ring-offset-transparent focus:border-none focus:border-transparent focus:shadow-none focus:outline-0 focus:ring-offset-transparent"
/>
</div>
<CommandList className="border-0">

View File

@@ -125,12 +125,12 @@ export const SingleTag: React.FC<SingleTagProps> = ({
</div>
</div>
<div className="col-span-1 my-auto text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-1 my-auto whitespace-nowrap text-center text-sm text-slate-500">
<div className="text-slate-900">{tagCountLoading ? <LoadingSpinner /> : <p>{tagCount}</p>}</div>
</div>
{!isReadOnly && (
<div className="col-span-1 my-auto flex items-center justify-center gap-2 text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-1 my-auto flex items-center justify-center gap-2 whitespace-nowrap text-center text-sm text-slate-500">
<div>
{isMergingTags ? (
<div className="w-24">
@@ -152,7 +152,7 @@ export const SingleTag: React.FC<SingleTagProps> = ({
<Button
variant="destructive"
size="sm"
className="font-medium text-slate-50 focus:border-transparent focus:ring-0 focus:shadow-transparent focus:ring-transparent focus:outline-transparent"
className="font-medium text-slate-50 focus:border-transparent focus:shadow-transparent focus:outline-transparent focus:ring-0 focus:ring-transparent"
onClick={() => setOpenDeleteTagDialog(true)}>
{t("common.delete")}
</Button>

View File

@@ -391,7 +391,7 @@ export const ElementFormInput = ({
return (
<div className="w-full">
{label && (
<div className="mt-3 mb-2 flex items-center justify-between">
<div className="mb-2 mt-3 flex items-center justify-between">
<Label htmlFor={id}>{label}</Label>
{id === "headline" && currentElement && updateElement && (
<div className="flex items-center space-x-2">
@@ -521,7 +521,7 @@ export const ElementFormInput = ({
return (
<div className="w-full">
{label && (
<div className="mt-3 mb-2 flex items-center justify-between">
<div className="mb-2 mt-3 flex items-center justify-between">
<Label htmlFor={id}>{label}</Label>
</div>
)}
@@ -568,7 +568,7 @@ export const ElementFormInput = ({
<div className="h-10 w-full"></div>
<div
ref={highlightContainerRef}
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent ${
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent ${
localSurvey.languages?.length > 1 ? "pr-24" : ""
}`}
dir="auto"

View File

@@ -51,7 +51,7 @@ export const StartFromScratchTemplate = ({
const cardContent = (
<>
<PlusCircleIcon className="text-brand-dark h-8 w-8 transition-all duration-150 group-hover:scale-110" />
<PlusCircleIcon className="h-8 w-8 text-brand-dark transition-all duration-150 group-hover:scale-110" />
<h3 className="text-md mb-1 mt-3 text-left font-bold text-slate-700">{customSurvey.name}</h3>
<p className="text-left text-xs text-slate-600">{customSurvey.description}</p>
{showCreateSurveyButton && (

View File

@@ -93,7 +93,7 @@ export const AddActionModal = ({
key={tab.title}
className={`mr-4 px-1 pb-3 focus:outline-none ${
activeTab === index
? "border-brand-dark border-b-2 font-semibold text-slate-900"
? "border-b-2 border-brand-dark font-semibold text-slate-900"
: "text-slate-500 hover:text-slate-700"
}`}
onClick={() => handleTabClick(index)}>

View File

@@ -38,7 +38,7 @@ export const AddElementButton = ({ addElement, project, isCxMode }: AddElementBu
)}>
<Collapsible.CollapsibleTrigger asChild className="group h-full w-full">
<div className="inline-flex">
<div className="bg-brand-dark flex w-10 items-center justify-center rounded-l-lg group-aria-expanded:rounded-bl-none group-aria-expanded:rounded-br">
<div className="flex w-10 items-center justify-center rounded-l-lg bg-brand-dark group-aria-expanded:rounded-bl-none group-aria-expanded:rounded-br">
<PlusIcon className="h-5 w-5 text-white" />
</div>
<div className="px-4 py-3">
@@ -67,7 +67,7 @@ export const AddElementButton = ({ addElement, project, isCxMode }: AddElementBu
onMouseEnter={() => setHoveredElementId(elementType.id)}
onMouseLeave={() => setHoveredElementId(null)}>
<div className="flex items-center">
<elementType.icon className="text-brand-dark -ml-0.5 mr-2 h-4 w-4" aria-hidden="true" />
<elementType.icon className="-ml-0.5 mr-2 h-4 w-4 text-brand-dark" aria-hidden="true" />
{elementType.label}
</div>
<div

View File

@@ -265,7 +265,7 @@ export const BlockCard = ({
</div>
<button
className="opacity-0 group-hover:opacity-100 hover:cursor-move"
className="opacity-0 hover:cursor-move group-hover:opacity-100"
aria-label="Drag to reorder block">
<GripIcon className="h-4 w-4" />
</button>

View File

@@ -177,7 +177,7 @@ export const BulkEditOptionsModal = ({
}
}}
rows={15}
className="focus:border-brand w-full rounded-md border border-slate-300 bg-white p-3 font-mono text-sm focus:outline-none"
className="w-full rounded-md border border-slate-300 bg-white p-3 font-mono text-sm focus:border-brand focus:outline-none"
placeholder={t("environments.surveys.edit.bulk_edit_description")}
/>
{validationError && <div className="text-sm text-red-600">{validationError}</div>}

View File

@@ -111,7 +111,7 @@ export const CTAElementForm = ({
description={t("environments.surveys.edit.button_external_description")}
childBorder
customContainerClass="p-0 mt-4">
<div className="flex flex-1 flex-col gap-2 px-4 pt-1 pb-4">
<div className="flex flex-1 flex-col gap-2 px-4 pb-4 pt-1">
<ElementFormInput
id="ctaButtonLabel"
value={element.ctaButtonLabel}

View File

@@ -185,7 +185,7 @@ export const EndScreenForm = ({
<div className="group relative">
{/* The highlight container is absolutely positioned behind the input */}
<div
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent`}
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent`}
dir="auto"
key={highlightedJSX.toString()}>
{highlightedJSX}

View File

@@ -172,7 +172,7 @@ export const FileUploadElementForm = ({
updateElement(elementIdx, { maxSizeInMB: Number.parseInt(e.target.value, 10) });
}}
className="mr-2 ml-2 inline w-20 bg-white text-center text-sm"
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
/>
MB
</p>

View File

@@ -158,7 +158,7 @@ export const HiddenFieldsCard = ({
<div
className={cn(
open ? "bg-slate-50" : "bg-white group-hover:bg-slate-50",
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none"
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none"
)}>
<EyeOff className="h-4 w-4" />
</div>
@@ -191,7 +191,7 @@ export const HiddenFieldsCard = ({
);
})
) : (
<p className="mt-2 text-sm text-slate-500 italic">
<p className="mt-2 text-sm italic text-slate-500">
{t("environments.surveys.edit.no_hidden_fields_yet_add_first_one_below")}
</p>
)}

View File

@@ -106,7 +106,7 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
className="h-full w-full cursor-pointer"
id="howToSendCardTrigger">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pr-5 pl-2">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
@@ -149,14 +149,14 @@ export const HowToSendCard = ({ localSurvey, setLocalSurvey, environment }: HowT
option.comingSoon
? "border-slate-200 bg-slate-50/50"
: option.id === localSurvey.type
? "border-brand-dark cursor-pointer bg-slate-50"
? "cursor-pointer border-brand-dark bg-slate-50"
: "cursor-pointer bg-slate-50"
)}
id={`howToSendCardOption-${option.id}`}>
<RadioGroupItem
value={option.id}
id={option.id}
className="aria-checked:border-brand-dark mx-5 disabled:border-slate-400 aria-checked:border-2"
className="mx-5 disabled:border-slate-400 aria-checked:border-2 aria-checked:border-brand-dark"
disabled={option.comingSoon}
/>
<div className="inline-flex items-center">

View File

@@ -124,7 +124,7 @@ export const LogoSettingsCard = ({
disabled && "cursor-not-allowed opacity-60 hover:bg-white"
)}>
<div className="inline-flex w-full px-4 py-4">
<div className="flex items-center pr-5 pl-2">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"

View File

@@ -203,7 +203,7 @@ export const RecontactOptionsCard = ({ localSurvey, setLocalSurvey }: RecontactO
<RadioGroupItem
value={option.id}
id={`waiting-time-${option.id}`}
className="aria-checked:border-brand-dark mx-5 disabled:border-slate-400 aria-checked:border-2"
className="mx-5 disabled:border-slate-400 aria-checked:border-2 aria-checked:border-brand-dark"
/>
<div>
<p className="font-semibold text-slate-700">{option.name}</p>
@@ -273,7 +273,7 @@ export const RecontactOptionsCard = ({ localSurvey, setLocalSurvey }: RecontactO
<RadioGroupItem
value={option.id}
id={`recontact-option-${option.id}`}
className="aria-checked:border-brand-dark mx-5 disabled:border-slate-400 aria-checked:border-2"
className="mx-5 disabled:border-slate-400 aria-checked:border-2 aria-checked:border-brand-dark"
/>
<div>
<p className="font-semibold text-slate-700">{option.name}</p>

View File

@@ -45,7 +45,7 @@ export const RedirectUrlForm = ({ localSurvey, endingCard, updateSurvey }: Redir
<div className="group relative">
{/* The highlight container is absolutely positioned behind the input */}
<div
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll px-3 py-2 text-center text-sm whitespace-nowrap text-transparent`}
className={`no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent`}
dir="auto"
key={highlightedJSX.toString()}>
{highlightedJSX}

View File

@@ -205,7 +205,7 @@ export const ResponseOptionsCard = ({
)}>
<Collapsible.CollapsibleTrigger asChild className="h-full w-full cursor-pointer">
<div className="inline-flex px-4 py-4">
<div className="flex items-center pr-5 pl-2">
<div className="flex items-center pl-2 pr-5">
<CheckIcon
strokeWidth={3}
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
@@ -243,7 +243,7 @@ export const ResponseOptionsCard = ({
value={localSurvey.autoComplete?.toString()}
onChange={handleInputResponse}
onBlur={handleInputResponseBlur}
className="mr-2 ml-2 inline w-20 bg-white text-center text-sm"
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
/>
{t("environments.surveys.edit.completed_responses")}
</p>
@@ -310,7 +310,7 @@ export const ResponseOptionsCard = ({
<Input
autoFocus
id="heading"
className="mt-2 mb-4 bg-white"
className="mb-4 mt-2 bg-white"
name="heading"
defaultValue={surveyClosedMessage.heading}
onChange={(e) => handleClosedSurveyMessageChange({ heading: e.target.value })}

View File

@@ -475,7 +475,7 @@ export const SurveyMenuBar = ({
/>
</div>
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:ml-4">
<div className="mt-3 flex items-center gap-2 sm:ml-4 sm:mt-0">
<AutoSaveIndicator isDraft={localSurvey.status === "draft"} lastSaved={lastAutoSaved} />
{!isStorageConfigured && (
<div>

View File

@@ -155,7 +155,7 @@ export const FollowUpItem = ({
</div>
</button>
<div className="absolute top-4 right-4 flex items-center">
<div className="absolute right-4 top-4 flex items-center">
<TooltipRenderer tooltipContent={t("common.delete")}>
<Button
variant="ghost"

View File

@@ -86,7 +86,7 @@ export const LinkSurveyWrapper = ({
)}
<div className="h-full w-full max-w-4xl space-y-6 px-1.5">
{isPreview && (
<div className="fixed top-0 left-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
<div />
Survey Preview 👀
<ResetProgressButton onClick={handleResetSurvey} />

View File

@@ -188,7 +188,7 @@ export const VerifyEmail = ({
{!emailSent && showPreviewQuestions && (
<div>
<p className="text-2xl font-bold">{t("s.question_preview")}</p>
<div className="bg-opacity-20 mt-4 flex max-h-[50vh] w-full flex-col overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 p-4 text-slate-700">
<div className="mt-4 flex max-h-[50vh] w-full flex-col overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 bg-opacity-20 p-4 text-slate-700">
{questions.map((question, index) => (
<p
key={index}

View File

@@ -157,9 +157,8 @@ describe("Metadata Utils", () => {
}));
// Re-import the function to use the updated mock
const { getBasicSurveyMetadata: getBasicSurveyMetadataWithCloudMock } = await import(
"./metadata-utils"
);
const { getBasicSurveyMetadata: getBasicSurveyMetadataWithCloudMock } =
await import("./metadata-utils");
const mockSurvey = {
id: mockSurveyId,

View File

@@ -19,7 +19,7 @@ export const SortOption = ({ option, sortBy, handleSortChange }: SortOptionProps
}}>
<div className="flex h-full w-full items-center space-x-2 px-2 py-1 hover:bg-slate-700">
<span
className={`h-4 w-4 rounded-full border ${sortBy === option.value ? "bg-brand-dark outline-brand-dark border-slate-900 outline" : "border-white"}`}></span>
className={`h-4 w-4 rounded-full border ${sortBy === option.value ? "border-slate-900 bg-brand-dark outline outline-brand-dark" : "border-white"}`}></span>
<p className="font-normal text-white">{option.label}</p>
</div>
</DropdownMenuItem>

View File

@@ -52,7 +52,7 @@ export const SurveyFilterDropdown = ({
<div className="flex h-full w-full items-center space-x-2 px-2 py-1 hover:bg-slate-700">
<Checkbox
checked={selectedOptions.includes(option.value)}
className={`bg-white ${selectedOptions.includes(option.value) ? "bg-brand-dark border-none" : ""}`}
className={`bg-white ${selectedOptions.includes(option.value) ? "border-none bg-brand-dark" : ""}`}
/>
<p className="font-normal text-white">{option.label}</p>
</div>

View File

@@ -39,7 +39,7 @@ export const TemplateContainerWithPreview = ({
{isTemplatePage && <MenuBar />}
<div className="relative z-0 flex flex-1 overflow-hidden">
<div className="flex-1 flex-col overflow-auto bg-slate-50">
<div className="mt-6 mb-3 ml-6 flex flex-col items-center justify-between md:flex-row md:items-end">
<div className="mb-3 ml-6 mt-6 flex flex-col items-center justify-between md:flex-row md:items-end">
<h1 className="text-2xl font-bold text-slate-800">
{isTemplatePage
? t("environments.surveys.templates.create_a_new_survey")

View File

@@ -36,8 +36,7 @@ const buttonVariants = cva(
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
loading?: boolean;
}

View File

@@ -46,7 +46,7 @@ export const ClientLogo = ({
target="_blank">
<ArrowUpRight
size={24}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform rounded-md bg-white/80 p-0.5 text-slate-700 opacity-0 transition-all duration-200 ease-in-out group-hover/link:opacity-100"
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform rounded-md bg-white/80 p-0.5 text-slate-700 opacity-0 transition-all duration-200 ease-in-out group-hover/link:opacity-100"
/>
</Link>
)}
@@ -69,7 +69,7 @@ export const ClientLogo = ({
e.preventDefault();
}
}}
className="rounded-md border border-dashed border-slate-400 bg-slate-200 px-6 py-3 text-xs whitespace-nowrap text-slate-900 opacity-50 backdrop-blur-sm hover:cursor-pointer hover:border-slate-600"
className="whitespace-nowrap rounded-md border border-dashed border-slate-400 bg-slate-200 px-6 py-3 text-xs text-slate-900 opacity-50 backdrop-blur-sm hover:cursor-pointer hover:border-slate-600"
target="_blank">
{t("common.add_logo")}
</Link>

View File

@@ -115,7 +115,7 @@ function CommandItem({ className, ...props }: React.ComponentProps<typeof Comman
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm data-[disabled=true]:pointer-events-none data-[selected=true]:cursor-pointer data-[selected=true]:bg-slate-100 data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"[&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm data-[disabled=true]:pointer-events-none data-[selected=true]:cursor-pointer data-[selected=true]:bg-slate-100 data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...props}

View File

@@ -259,7 +259,7 @@ export function ConditionsEditor({
</DropdownMenu>
</div>
{quotaError && isSubmitted && (
<p className="text-error mt-2 w-full text-right text-sm">{quotaError}</p>
<p className="mt-2 w-full text-right text-sm text-error">{quotaError}</p>
)}
</div>
);

View File

@@ -68,7 +68,7 @@ export const DataTableHeader = <T,>({
onTouchStart={header.getResizeHandler()}
data-testid="column-resize-handle"
className={cn(
"absolute top-0 right-0 hidden h-full w-1 cursor-col-resize bg-slate-500",
"absolute right-0 top-0 hidden h-full w-1 cursor-col-resize bg-slate-500",
header.column.getIsResizing() ? "bg-black" : "bg-slate-500",
!header.column.getCanResize() ? "hidden" : "group-hover:block"
)}></button>

View File

@@ -141,7 +141,7 @@ export const SelectedRowSettings = <T,>({
return (
<>
<div className="bg-primary flex items-center gap-x-2 rounded-md p-1 px-2 text-xs text-white">
<div className="flex items-center gap-x-2 rounded-md bg-primary p-1 px-2 text-xs text-white">
<div className="lowercase">{`${selectedRowCount} ${selectedTypeLabel} ${t("common.selected")}`}</div>
<Separator />
<Button

View File

@@ -83,7 +83,7 @@ const DialogContent = React.forwardRef<
{...props}>
{children}
{!hideCloseButton && (
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent absolute right-3 top-[-0.25rem] z-10 rounded-sm bg-transparent transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:text-slate-500">
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring absolute right-3 top-[-0.25rem] z-10 rounded-sm bg-transparent transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-slate-500">
<X className="size-4 text-slate-500" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
@@ -105,7 +105,7 @@ const DialogHeader = ({ className, ...props }: DialogHeaderProps) => (
<div
className={cn(
"sticky top-[-32px] z-10 flex flex-shrink-0 flex-col gap-y-1 bg-white text-left",
"[&>svg]:text-primary [&>svg]:absolute [&>svg]:size-4 [&>svg~*]:min-h-4 [&>svg~*]:items-center [&>svg~*]:pl-6 sm:[&>svg~*]:flex",
"[&>svg]:absolute [&>svg]:size-4 [&>svg]:text-primary [&>svg~*]:min-h-4 [&>svg~*]:items-center [&>svg~*]:pl-6 sm:[&>svg~*]:flex",
className
)}
{...props}
@@ -150,7 +150,7 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-primary text-sm font-medium leading-none", className)}
className={cn("text-sm font-medium leading-none text-primary", className)}
{...props}
/>
));

View File

@@ -123,7 +123,7 @@ const LinkEditorContent = ({ editor, open, setOpen }: LinkEditorProps) => {
<Input
type="url"
required
className="focus:border-brand-dark h-9 min-w-80 bg-white"
className="h-9 min-w-80 bg-white focus:border-brand-dark"
ref={inputRef}
value={linkUrl}
placeholder="https://example.com"

View File

@@ -133,7 +133,7 @@ const FormError = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
}
return (
<p ref={ref} id={formMessageId} className={cn("text-error text-sm", className)} {...props}>
<p ref={ref} id={formMessageId} className={cn("text-sm text-error", className)} {...props}>
{body}
</p>
);

View File

@@ -226,7 +226,7 @@ export const InputCombobox: React.FC<InputComboboxProps> = ({
tabIndex={0}
aria-controls="options"
aria-expanded={open}
className={cn("flex w-full cursor-pointer items-center justify-end bg-white pr-2 h-10", {
className={cn("flex h-10 w-full cursor-pointer items-center justify-end bg-white pr-2", {
"w-10 justify-center pr-0": withInput && inputType !== "dropdown",
"pointer-events-none": isClearing,
})}>

View File

@@ -1,8 +1,10 @@
import * as React from "react";
import { cn } from "@/lib/cn";
export interface InputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "crossOrigin" | "dangerouslySetInnerHTML"> {
export interface InputProps extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"crossOrigin" | "dangerouslySetInnerHTML"
> {
crossOrigin?: "" | "anonymous" | "use-credentials" | undefined;
dangerouslySetInnerHTML?: {
__html: string;
@@ -14,7 +16,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, isInv
return (
<input
className={cn(
"focus:border-brand-dark flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300",
"flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300",
className,
isInvalid && "border border-red-500 focus:border-red-500"
)}

View File

@@ -39,7 +39,7 @@ export const Card: React.FC<CardProps> = ({
<div className="absolute right-4 top-4 flex items-center rounded bg-slate-100 px-2 py-1 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-400">
{connected === true ? (
<span className="relative mr-1 flex h-2 w-2">
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
<span className="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-green-500 opacity-75"></span>
<span className="relative inline-flex h-2 w-2 rounded-full bg-green-500"></span>
</span>
) : (

Some files were not shown because too many files have changed in this diff Show More