Compare commits

..

1 Commits

Author SHA1 Message Date
Dhruwang
ce82dbd517 fix: z-index for multi select question with dropdwon display type 2026-03-05 14:15:05 +05:30
316 changed files with 5620 additions and 5986 deletions

2
.husky/post-checkout Normal file
View File

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

View File

@@ -10,20 +10,25 @@
"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.15",
"@storybook/addon-links": "10.2.15",
"@storybook/addon-onboarding": "10.2.15",
"@storybook/react-vite": "10.2.15",
"@storybook/addon-a11y": "10.2.14",
"@storybook/addon-links": "10.2.14",
"@storybook/addon-onboarding": "10.2.14",
"@storybook/react-vite": "10.2.14",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@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",
"storybook": "10.2.15",
"prop-types": "15.8.1",
"storybook": "10.2.14",
"vite": "7.3.1",
"@storybook/addon-docs": "10.2.15"
"@storybook/addon-docs": "10.2.14"
}
}

View File

@@ -1,6 +0,0 @@
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="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-slate-400 opacity-75"></span>
<span className="animate-ping-slow absolute inline-flex h-full w-full 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 right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-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 right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-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(
"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"
"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"
)}>
<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 right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-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 right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-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 right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
export const ZOrganizationTeam = z.object({
id: z.cuid2(),
id: z.string().cuid2(),
name: z.string(),
});

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:outline-none focus:ring-0 focus:ring-transparent"
"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"
)}>
{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-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
<p className="w-2/3 text-sm text-balance 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="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
value={field.value}
onChange={(password) => field.onChange(password)}
/>

View File

@@ -110,8 +110,8 @@ export const getResponseCountAction = authenticatedActionClient
const ZGetDisplaysWithContactAction = z.object({
surveyId: ZId,
limit: z.int().min(1).max(100),
offset: z.int().nonnegative(),
limit: z.number().int().min(1).max(100),
offset: z.number().int().nonnegative(),
});
export const getDisplaysWithContactAction = authenticatedActionClient

View File

@@ -77,7 +77,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
)}>
<button
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
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"
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"
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="w-full rounded-t-lg border border-slate-200 bg-brand-dark transition-all group-hover:brightness-110"
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 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={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
className={`bg-brand-dark h-full ${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="whitespace-pre-wrap font-mono text-xs text-slate-600">
<pre className="font-mono text-xs whitespace-pre-wrap 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(
"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"
"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"
)}
{...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 right-3 top-3" text={t("common.new")} />
<Badge size="normal" type="success" className="absolute top-3 right-3" text={t("common.new")} />
</button>
<Link
href={`/environments/${environmentId}/settings/notifications`}

View File

@@ -1095,7 +1095,7 @@ export const getResponsesForSummary = reactCache(
[limit, ZOptionalNumber],
[offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()],
[cursor, z.cuid2().optional()]
[cursor, z.string().cuid2().optional()]
);
const queryLimit = limit ?? RESPONSES_PER_PAGE;

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 outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
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"
/>
)}
<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 select-none bg-slate-200">
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
{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 whitespace-nowrap text-center text-sm text-slate-500">
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap 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 select-none bg-slate-200">
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
{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 whitespace-nowrap text-center text-sm text-slate-500">
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap 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

@@ -50,7 +50,7 @@ export const GET = withV1ApiWrapper({
{
environmentId: params.environmentId,
url: req.url,
validationError: cuidValidation.error.issues[0]?.message,
validationError: cuidValidation.error.errors[0]?.message,
},
"Invalid CUID v1 format detected"
);

View File

@@ -6,138 +6,140 @@ 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: "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%",
width: "100%",
height: "100%",
alignItems: "center",
backgroundColor: "white",
backgroundColor: brandColor ? brandColor + "BF" : "#0000BFBF", // /75 opacity is approximately BF in hex
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%",
justifyContent: "space-between",
}}>
<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",
paddingLeft: "2rem",
paddingRight: "2rem",
width: "100%",
justifyContent: "space-between",
}}>
<h2
<div
style={{
display: "flex",
flexDirection: "column",
fontSize: "2rem",
fontWeight: "700",
letterSpacing: "-0.025em",
color: "#0f172a",
textAlign: "left",
marginTop: "3.75rem",
paddingLeft: "2rem",
paddingRight: "2rem",
}}>
{name}
</h2>
<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>
<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", justifyContent: "flex-end", marginRight: "2.5rem" }}>
<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",
borderRadius: "1rem",
position: "absolute",
right: "-0.5rem",
marginTop: "0.5rem",
}}>
Begin!
<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>
</div>
</div>
</div>
</div>
</div>
</div>,
),
{
width: 800,
height: 400,

View File

@@ -6,7 +6,7 @@ import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const deleteSurvey = async (surveyId: string) => {
validateInputs([surveyId, z.cuid2()]);
validateInputs([surveyId, z.string().cuid2()]);
try {
const deletedSurvey = await prisma.survey.delete({

View File

@@ -101,9 +101,7 @@ describe("verifyRecaptchaToken", () => {
},
signal: {},
};
vi.spyOn(global, "AbortController").mockImplementation(function AbortController() {
return abortController as any;
});
vi.spyOn(global, "AbortController").mockImplementation(() => abortController as any);
(global.fetch as any).mockImplementation(() => new Promise(() => {}));
verifyRecaptchaToken("token", 0.5);
vi.advanceTimersByTime(5000);

View File

@@ -131,11 +131,13 @@ 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 });
@@ -183,11 +185,13 @@ 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 });
@@ -229,11 +233,13 @@ 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 });
@@ -285,11 +291,13 @@ 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 });
@@ -339,11 +347,13 @@ 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);
@@ -366,8 +376,9 @@ 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");
@@ -399,8 +410,9 @@ 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 });
@@ -423,8 +435,9 @@ 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);
@@ -449,11 +462,13 @@ 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

@@ -1,4 +1,4 @@
import * as cuid2 from "@paralleldrive/cuid2";
import cuid2 from "@paralleldrive/cuid2";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as crypto from "@/lib/crypto";
import { generateSurveySingleUseId, validateSurveySingleUseId } from "./singleUseSurveys";
@@ -20,6 +20,10 @@ vi.mock("@paralleldrive/cuid2", () => {
const isCuidMock = vi.fn();
return {
default: {
createId: createIdMock,
isCuid: isCuidMock,
},
createId: createIdMock,
isCuid: isCuidMock,
};

View File

@@ -1,10 +1,10 @@
import { createId, isCuid } from "@paralleldrive/cuid2";
import cuid2 from "@paralleldrive/cuid2";
import { ENCRYPTION_KEY } from "@/lib/constants";
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
// generate encrypted single use id for the survey
export const generateSurveySingleUseId = (isEncrypted: boolean): string => {
const cuid = createId();
const cuid = cuid2.createId();
if (!isEncrypted) {
return cuid;
}
@@ -30,7 +30,7 @@ export const validateSurveySingleUseId = (surveySingleUseId: string): string | u
return undefined;
}
if (isCuid(decryptedCuid)) {
if (cuid2.isCuid(decryptedCuid)) {
return decryptedCuid;
} else {
return undefined;

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

@@ -159,7 +159,7 @@ export const BREVO_LIST_ID = env.BREVO_LIST_ID;
export const UNSPLASH_ACCESS_KEY = env.UNSPLASH_ACCESS_KEY;
export const UNSPLASH_ALLOWED_DOMAINS = ["api.unsplash.com"];
export const STRIPE_API_VERSION = "2026-02-25.clover";
export const STRIPE_API_VERSION = "2024-06-20";
// Maximum number of attribute classes allowed:
export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150;

View File

@@ -71,8 +71,8 @@ export const getDisplaysBySurveyIdWithContact = reactCache(
async (surveyId: string, limit?: number, offset?: number): Promise<TDisplayWithContact[]> => {
validateInputs(
[surveyId, ZId],
[limit, z.int().min(1).optional()],
[offset, z.int().nonnegative().optional()]
[limit, z.number().int().min(1).optional()],
[offset, z.number().int().nonnegative().optional()]
);
try {

View File

@@ -14,7 +14,7 @@ export const env = createEnv({
CRON_SECRET: z.string().optional(),
BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.url(),
DATABASE_URL: z.string().url(),
DEBUG: z.enum(["1", "0"]).optional(),
AUTH_DEFAULT_TEAM_ID: z.string().optional(),
AUTH_SKIP_INVITE_FOR_SSO: z.enum(["1", "0"]).optional(),
@@ -23,7 +23,7 @@ export const env = createEnv({
EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
ENCRYPTION_KEY: z.string(),
ENTERPRISE_LICENSE_KEY: z.string().optional(),
ENVIRONMENT: z.enum(["production", "staging"]).prefault("production"),
ENVIRONMENT: z.enum(["production", "staging"]).default("production"),
GITHUB_ID: z.string().optional(),
GITHUB_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
@@ -31,20 +31,21 @@ export const env = createEnv({
GOOGLE_SHEETS_CLIENT_ID: z.string().optional(),
GOOGLE_SHEETS_CLIENT_SECRET: z.string().optional(),
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
HTTP_PROXY: z.url().optional(),
HTTPS_PROXY: z.url().optional(),
HTTP_PROXY: z.string().url().optional(),
HTTPS_PROXY: z.string().url().optional(),
IMPRINT_URL: z
.string()
.url()
.optional()
.or(z.string().refine((str) => str === "")),
IMPRINT_ADDRESS: z.string().optional(),
INVITE_DISABLED: z.enum(["1", "0"]).optional(),
CHATWOOT_WEBSITE_TOKEN: z.string().optional(),
CHATWOOT_BASE_URL: z.url().optional(),
CHATWOOT_BASE_URL: z.string().url().optional(),
IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error", "fatal"]).optional(),
MAIL_FROM: z.email().optional(),
NEXTAUTH_URL: z.url().optional(),
MAIL_FROM: z.string().email().optional(),
NEXTAUTH_URL: z.string().url().optional(),
NEXTAUTH_SECRET: z.string().optional(),
MAIL_FROM_NAME: z.string().optional(),
NOTION_OAUTH_CLIENT_ID: z.string().optional(),
@@ -57,9 +58,10 @@ export const env = createEnv({
REDIS_URL:
process.env.NODE_ENV === "test"
? z.string().optional()
: z.url("REDIS_URL is required for caching, rate limiting, and audit logging"),
: z.string().url("REDIS_URL is required for caching, rate limiting, and audit logging"),
PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
PRIVACY_URL: z
.string()
.url()
.optional()
.or(z.string().refine((str) => str === "")),
@@ -84,6 +86,7 @@ export const env = createEnv({
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
PUBLIC_URL: z
.string()
.url()
.refine(
(url) => {
@@ -95,11 +98,12 @@ export const env = createEnv({
}
},
{
error: "PUBLIC_URL must be a valid URL with a proper host (e.g., https://example.com)",
message: "PUBLIC_URL must be a valid URL with a proper host (e.g., https://example.com)",
}
)
.optional(),
TERMS_URL: z
.string()
.url()
.optional()
.or(z.string().refine((str) => str === "")),
@@ -108,7 +112,7 @@ export const env = createEnv({
RECAPTCHA_SITE_KEY: z.string().optional(),
RECAPTCHA_SECRET_KEY: z.string().optional(),
VERCEL_URL: z.string().optional(),
WEBAPP_URL: z.url().optional(),
WEBAPP_URL: z.string().url().optional(),
UNSPLASH_ACCESS_KEY: z.string().optional(),
NODE_ENV: z.enum(["development", "production", "test"]).optional(),

View File

@@ -267,7 +267,7 @@ export const getResponses = reactCache(
[limit, ZOptionalNumber],
[offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()],
[cursor, z.cuid2().optional()]
[cursor, z.string().cuid2().optional()]
);
limit = limit ?? RESPONSES_PER_PAGE;

View File

@@ -1,5 +1,5 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { ActionClass, Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
@@ -114,12 +114,7 @@ export const selectSurvey = {
slug: true,
} satisfies Prisma.SurveySelect;
type TriggerWithActionClassId = { actionClass: { id: string } };
export const checkTriggersValidity = (
triggers: TriggerWithActionClassId[] | null | undefined,
actionClasses: Array<{ id: string }>
) => {
export const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: ActionClass[]) => {
if (!triggers) return;
// check if all the triggers are valid
@@ -138,14 +133,14 @@ export const checkTriggersValidity = (
};
export const handleTriggerUpdates = (
updatedTriggers: TriggerWithActionClassId[] | null | undefined,
currentTriggers: TriggerWithActionClassId[] | null | undefined,
actionClasses: Array<{ id: string }>
updatedTriggers: TSurvey["triggers"],
currentTriggers: TSurvey["triggers"],
actionClasses: ActionClass[]
) => {
if (!updatedTriggers) return {};
checkTriggersValidity(updatedTriggers, actionClasses);
const currentTriggerIds = (currentTriggers ?? []).map((trigger) => trigger.actionClass.id);
const currentTriggerIds = currentTriggers.map((trigger) => trigger.actionClass.id);
const updatedTriggerIds = updatedTriggers.map((trigger) => trigger.actionClass.id);
// added triggers are triggers that are not in the current triggers and are there in the new triggers
@@ -154,7 +149,7 @@ export const handleTriggerUpdates = (
);
// deleted triggers are triggers that are not in the new triggers and are there in the current triggers
const deletedTriggers = (currentTriggers ?? []).filter(
const deletedTriggers = currentTriggers.filter(
(trigger) => !updatedTriggerIds.includes(trigger.actionClass.id)
);

View File

@@ -52,7 +52,7 @@ export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
});
export const getUserByEmail = reactCache(async (email: string): Promise<TUser | null> => {
validateInputs([email, z.email()]);
validateInputs([email, z.string().email()]);
try {
const user = await prisma.user.findFirst({

View File

@@ -1,4 +1,4 @@
import * as cuid2 from "@paralleldrive/cuid2";
import cuid2 from "@paralleldrive/cuid2";
import { beforeEach, describe, expect, test, vi } from "vitest";
import * as crypto from "@/lib/crypto";
import { env } from "@/lib/env";

View File

@@ -1,10 +1,10 @@
import { createId } from "@paralleldrive/cuid2";
import cuid2 from "@paralleldrive/cuid2";
import { symmetricEncrypt } from "@/lib/crypto";
import { env } from "@/lib/env";
// generate encrypted single use id for the survey
export const generateSurveySingleUseId = (isEncrypted: boolean): string => {
const cuid = createId();
const cuid = cuid2.createId();
if (!isEncrypted) {
return cuid;
}

View File

@@ -154,7 +154,7 @@ export const deleteTagOnResponseAction = authenticatedActionClient.schema(ZDelet
const ZDeleteResponseAction = z.object({
responseId: ZId,
decrementQuotas: z.boolean().prefault(false),
decrementQuotas: z.boolean().default(false),
});
export const deleteResponseAction = authenticatedActionClient.schema(ZDeleteResponseAction).action(

View File

@@ -42,7 +42,7 @@ export const SingleResponseCardBody = ({
return (
<span
key={index}
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">
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">
@{part}
</span>
);

View File

@@ -1,23 +1,22 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZOverallHealthStatus = z
.object({
main_database: z
.boolean()
.meta({
example: true,
})
.describe("Main database connection status - true if database is reachable and running"),
cache_database: z
.boolean()
.meta({
example: true,
})
.describe("Cache database connection status - true if cache database is reachable and running"),
main_database: z.boolean().openapi({
description: "Main database connection status - true if database is reachable and running",
example: true,
}),
cache_database: z.boolean().openapi({
description: "Cache database connection status - true if cache database is reachable and running",
example: true,
}),
})
.meta({
.openapi({
title: "Health Check Response",
})
.describe("Health check status for critical application dependencies");
description: "Health check status for critical application dependencies",
});
export type OverallHealthStatus = z.infer<typeof ZOverallHealthStatus>;

View File

@@ -1,22 +1,26 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
extendZodWithOpenApi(z);
export const ZContactAttributeKeyIdSchema = z
.string()
.cuid2()
.meta({
id: "contactAttributeKeyId",
.openapi({
ref: "contactAttributeKeyId",
description: "The ID of the contact attribute key",
param: {
name: "id",
in: "path",
},
})
.describe("The ID of the contact attribute key");
});
export const ZContactAttributeKeyUpdateSchema = ZContactAttributeKey.pick({
name: true,
description: true,
}).meta({
id: "contactAttributeKeyUpdate",
}).openapi({
ref: "contactAttributeKeyUpdate",
description: "A contact attribute key to update. Key cannot be changed.",
});

View File

@@ -17,7 +17,7 @@ export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = {
description: "Gets contact attribute keys from the database.",
tags: ["Management API - Contact Attribute Keys"],
requestParams: {
query: ZGetContactAttributeKeysFilter,
query: ZGetContactAttributeKeysFilter.sourceType(),
},
responses: {
"200": {

View File

@@ -17,7 +17,7 @@ export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
query: ZGetContactAttributeKeysFilter,
query: ZGetContactAttributeKeysFilter.sourceType(),
},
handler: async ({ authentication, parsedInput }) => {
const { query } = parsedInput;
@@ -49,7 +49,7 @@ export const POST = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
body: ZContactAttributeKeyInput,
body: ZContactAttributeKeyInput.sourceType(),
},
handler: async ({ authentication, parsedInput, auditLog }) => {
const { body } = parsedInput;

View File

@@ -1,10 +1,13 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
extendZodWithOpenApi(z);
export const ZGetContactAttributeKeysFilter = ZGetFilter.extend({
environmentId: z.cuid2().optional().describe("The environment ID to filter by"),
environmentId: z.string().cuid2().optional().describe("The environment ID to filter by"),
})
.refine(
(data) => {
@@ -34,15 +37,15 @@ export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
// Enforce safe identifier format for key
if (!isSafeIdentifier(data.key)) {
ctx.addIssue({
code: "custom",
code: z.ZodIssueCode.custom,
message:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
path: ["key"],
});
}
})
.meta({
id: "contactAttributeKeyInput",
.openapi({
ref: "contactAttributeKeyInput",
description: "Input data for creating or updating a contact attribute",
});

View File

@@ -1,21 +1,25 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZResponse } from "@formbricks/database/zod/responses";
extendZodWithOpenApi(z);
export const ZResponseIdSchema = z
.string()
.cuid2()
.meta({
id: "responseId",
.openapi({
ref: "responseId",
description: "The ID of the response",
param: {
name: "id",
in: "path",
},
})
.describe("The ID of the response");
});
export const ZResponseUpdateSchema = ZResponse.omit({
id: true,
surveyId: true,
}).meta({
id: "responseUpdate",
}).openapi({
ref: "responseUpdate",
description: "A response to update.",
});

View File

@@ -13,7 +13,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
summary: "Get responses",
description: "Gets responses from the database.",
requestParams: {
query: ZGetResponsesFilter,
query: ZGetResponsesFilter.sourceType(),
},
tags: ["Management API - Responses"],
responses: {

View File

@@ -19,7 +19,7 @@ export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
query: ZGetResponsesFilter,
query: ZGetResponsesFilter.sourceType(),
},
handler: async ({ authentication, parsedInput }) => {
const { query } = parsedInput;

View File

@@ -3,7 +3,7 @@ import { ZResponse } from "@formbricks/database/zod/responses";
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
export const ZGetResponsesFilter = ZGetFilter.extend({
surveyId: z.cuid2().optional(),
surveyId: z.string().cuid2().optional(),
contactId: z.string().optional(),
}).refine(
(data) => {

View File

@@ -23,7 +23,7 @@ export const getPersonalizedSurveyLink: ZodOpenApiOperationObject = {
schema: makePartialSchema(
z.object({
data: z.object({
surveyUrl: z.url(),
surveyUrl: z.string().url(),
expiresAt: z
.string()
.nullable()

View File

@@ -1,18 +1,23 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZContactLinkParams = z.object({
surveyId: z
.string()
.cuid2()
.meta({
.openapi({
description: "The ID of the survey",
param: { name: "surveyId", in: "path" },
})
.describe("The ID of the survey"),
}),
contactId: z
.string()
.cuid2()
.meta({
.openapi({
description: "The ID of the contact",
param: { name: "contactId", in: "path" },
})
.describe("The ID of the contact"),
}),
});
export const ZContactLinkQuery = z.object({

View File

@@ -1,19 +1,24 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
extendZodWithOpenApi(z);
export const ZContactLinksBySegmentParams = z.object({
surveyId: z
.string()
.cuid2()
.meta({
.openapi({
description: "The ID of the survey",
param: { name: "surveyId", in: "path" },
})
.describe("The ID of the survey"),
}),
segmentId: z
.string()
.cuid2()
.meta({
.openapi({
description: "The ID of the segment",
param: { name: "segmentId", in: "path" },
})
.describe("The ID of the segment"),
}),
});
export const ZContactLinksBySegmentQuery = ZGetFilter.pick({
@@ -25,7 +30,7 @@ export const ZContactLinksBySegmentQuery = ZGetFilter.pick({
.min(1)
.max(365)
.nullish()
.prefault(null)
.default(null)
.describe("Number of days until the generated JWT expires. If not provided, there is no expiration."),
attributeKeys: z
.string()
@@ -47,7 +52,7 @@ export type TContactWithAttributes = {
export const ZContactLinkResponse = z.object({
contactId: z.string().describe("The ID of the contact"),
surveyUrl: z.url().describe("Personalized survey link"),
surveyUrl: z.string().url().describe("Personalized survey link"),
expiresAt: z.string().nullable().describe("The date and time the link expires, null if no expiration"),
attributes: z.record(z.string(), z.string()).describe("The attributes of the contact"),
});

View File

@@ -1,12 +1,16 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const surveyIdSchema = z
.string()
.cuid2()
.meta({
id: "surveyId",
.openapi({
ref: "surveyId",
description: "The ID of the survey",
param: {
name: "id",
in: "path",
},
})
.describe("The ID of the survey");
});

View File

@@ -1,12 +1,15 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZSurveyWithoutQuestionType } from "@formbricks/database/zod/surveys";
extendZodWithOpenApi(z);
export const ZGetSurveysFilter = z
.object({
limit: z.coerce.number().positive().min(1).max(100).optional().prefault(10),
skip: z.coerce.number().nonnegative().optional().prefault(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().prefault("createdAt"),
order: z.enum(["asc", "desc"]).optional().prefault("desc"),
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
skip: z.coerce.number().nonnegative().optional().default(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
order: z.enum(["asc", "desc"]).optional().default("desc"),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
surveyType: z.enum(["link", "app"]).optional(),
@@ -20,7 +23,7 @@ export const ZGetSurveysFilter = z
return true;
},
{
error: "startDate must be before endDate",
message: "startDate must be before endDate",
}
);
@@ -66,8 +69,8 @@ export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({
inlineTriggers: true,
displayPercentage: true,
})
.meta({
id: "surveyInput",
.openapi({
ref: "surveyInput",
description: "A survey input object for creating or updating surveys",
});

View File

@@ -1,16 +1,20 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
extendZodWithOpenApi(z);
export const ZWebhookIdSchema = z
.string()
.cuid2()
.meta({
id: "webhookId",
.openapi({
ref: "webhookId",
description: "The ID of the webhook",
param: {
name: "id",
in: "path",
},
})
.describe("The ID of the webhook");
});
export const ZWebhookUpdateSchema = ZWebhook.omit({
id: true,
@@ -18,7 +22,7 @@ export const ZWebhookUpdateSchema = ZWebhook.omit({
updatedAt: true,
environmentId: true,
secret: true,
}).meta({
id: "webhookUpdate",
}).openapi({
ref: "webhookUpdate",
description: "A webhook to update.",
});

View File

@@ -13,7 +13,7 @@ export const getWebhooksEndpoint: ZodOpenApiOperationObject = {
summary: "Get webhooks",
description: "Gets webhooks from the database.",
requestParams: {
query: ZGetWebhooksFilter,
query: ZGetWebhooksFilter.sourceType(),
},
tags: ["Management API - Webhooks"],
responses: {

View File

@@ -11,7 +11,7 @@ export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
query: ZGetWebhooksFilter,
query: ZGetWebhooksFilter.sourceType(),
},
handler: async ({ authentication, parsedInput }) => {
const { query } = parsedInput;

View File

@@ -3,7 +3,7 @@ import { ZWebhook } from "@formbricks/database/zod/webhooks";
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
export const ZGetWebhooksFilter = ZGetFilter.extend({
surveyIds: z.array(z.cuid2()).optional(),
surveyIds: z.array(z.string().cuid2()).optional(),
}).refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {

View File

@@ -1,5 +1,6 @@
import * as yaml from "yaml";
import { createDocument } from "zod-openapi";
import { z } from "zod";
import { createDocument, extendZodWithOpenApi } from "zod-openapi";
import { ZApiKeyData } from "@formbricks/database/zod/api-keys";
import { ZContact } from "@formbricks/database/zod/contact";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
@@ -26,6 +27,8 @@ import { rolePaths } from "@/modules/api/v2/roles/lib/openapi";
import { bulkContactPaths } from "@/modules/ee/contacts/api/v2/management/contacts/bulk/lib/openapi";
import { contactPaths } from "@/modules/ee/contacts/api/v2/management/contacts/lib/openapi";
extendZodWithOpenApi(z);
const document = createDocument({
openapi: "3.1.0",
info: {

View File

@@ -14,7 +14,7 @@ export const getProjectTeamsEndpoint: ZodOpenApiOperationObject = {
summary: "Get project teams",
description: "Gets projectTeams from the database.",
requestParams: {
query: ZGetProjectTeamsFilter,
query: ZGetProjectTeamsFilter.sourceType(),
path: z.object({
organizationId: ZOrganizationIdSchema,
}),

View File

@@ -24,7 +24,7 @@ export async function GET(request: Request, props: { params: Promise<{ organizat
return authenticatedApiClient({
request,
schemas: {
query: ZGetProjectTeamsFilter,
query: ZGetProjectTeamsFilter.sourceType(),
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,

View File

@@ -3,8 +3,8 @@ import { ZProjectTeam } from "@formbricks/database/zod/project-teams";
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
export const ZGetProjectTeamsFilter = ZGetFilter.extend({
teamId: z.cuid2().optional(),
projectId: z.cuid2().optional(),
teamId: z.string().cuid2().optional(),
projectId: z.string().cuid2().optional(),
}).refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
@@ -28,8 +28,8 @@ export const ZProjectTeamInput = ZProjectTeam.pick({
export type TProjectTeamInput = z.infer<typeof ZProjectTeamInput>;
export const ZGetProjectTeamUpdateFilter = z.object({
teamId: z.cuid2(),
projectId: z.cuid2(),
teamId: z.string().cuid2(),
projectId: z.string().cuid2(),
});
export const ZProjectZTeamUpdateSchema = ZProjectTeam.pick({

View File

@@ -1,16 +1,20 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZTeam } from "@formbricks/database/zod/teams";
extendZodWithOpenApi(z);
export const ZTeamIdSchema = z
.string()
.cuid2()
.meta({
id: "teamId",
.openapi({
ref: "teamId",
description: "The ID of the team",
param: {
name: "id",
in: "path",
},
})
.describe("The ID of the team");
});
export const ZTeamUpdateSchema = ZTeam.omit({
id: true,

View File

@@ -21,7 +21,7 @@ export const getTeamsEndpoint: ZodOpenApiOperationObject = {
path: z.object({
organizationId: ZOrganizationIdSchema,
}),
query: ZGetTeamsFilter,
query: ZGetTeamsFilter.sourceType(),
},
tags: ["Organizations API - Teams"],
responses: {

View File

@@ -16,7 +16,7 @@ export const GET = async (request: NextRequest, props: { params: Promise<{ organ
authenticatedApiClient({
request,
schemas: {
query: ZGetTeamsFilter,
query: ZGetTeamsFilter.sourceType(),
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,

View File

@@ -1,12 +1,16 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZOrganizationIdSchema = z
.string()
.cuid2()
.meta({
id: "organizationId",
.openapi({
ref: "organizationId",
description: "The ID of the organization",
param: {
name: "organizationId",
in: "path",
},
})
.describe("The ID of the organization");
});

View File

@@ -17,7 +17,7 @@ export const getUsersEndpoint: ZodOpenApiOperationObject = {
path: z.object({
organizationId: ZOrganizationIdSchema,
}),
query: ZGetUsersFilter,
query: ZGetUsersFilter.sourceType(),
},
tags: ["Organizations API - Users"],
responses: {

View File

@@ -24,7 +24,7 @@ export const GET = async (request: NextRequest, props: { params: Promise<{ organ
authenticatedApiClient({
request,
schemas: {
query: ZGetUsersFilter,
query: ZGetUsersFilter.sourceType(),
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,

View File

@@ -1,10 +1,10 @@
import { z } from "zod";
export const ZGetFilter = z.object({
limit: z.coerce.number().min(1).max(250).optional().prefault(50).describe("Number of items to return"),
skip: z.coerce.number().min(0).optional().prefault(0).describe("Number of items to skip"),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().prefault("createdAt").describe("Sort by field"),
order: z.enum(["asc", "desc"]).optional().prefault("desc").describe("Sort order"),
limit: z.coerce.number().min(1).max(250).optional().default(50).describe("Number of items to return"),
skip: z.coerce.number().min(0).optional().default(0).describe("Number of items to skip"),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt").describe("Sort by field"),
order: z.enum(["asc", "desc"]).optional().default("desc").describe("Sort order"),
startDate: z.coerce.date().optional().describe("Start date"),
endDate: z.coerce.date().optional().describe("End date"),
filterDateField: z.enum(["createdAt", "updatedAt"]).optional().describe("Date field to filter by"),

View File

@@ -1,6 +1,6 @@
import { z } from "zod";
export function responseWithMetaSchema<T extends z.ZodType>(contentSchema: T) {
export function responseWithMetaSchema<T extends z.ZodTypeAny>(contentSchema: T) {
return z.object({
data: z.array(contentSchema).optional(),
meta: z

View File

@@ -7,12 +7,7 @@ import { getUserByEmail } from "@/lib/user/service";
import { actionClient } from "@/lib/utils/action-client";
const ZCreateEmailTokenAction = z.object({
email: z
.email({
error: "Invalid email",
})
.min(5)
.max(255),
email: z.string().min(5).max(255).email({ message: "Invalid email" }),
});
export const createEmailTokenAction = actionClient

View File

@@ -15,7 +15,7 @@ export const EmailChangeWithoutVerificationSuccessPage = async () => {
}
return (
<div className="flex min-h-screen bg-gradient-radial from-slate-200 to-slate-50">
<div className="bg-gradient-radial flex min-h-screen 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

@@ -13,7 +13,7 @@ import { Button } from "@/modules/ui/components/button";
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
const ZForgotPasswordForm = z.object({
email: z.email(),
email: z.string().email(),
});
type TForgotPasswordForm = z.infer<typeof ZForgotPasswordForm>;
@@ -60,7 +60,7 @@ export const ForgotPasswordForm = () => {
onChange={(e) => field.onChange(e)}
autoComplete="email"
required
className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
/>
</FormControl>
{error?.message && <FormError className="text-left">{error.message}</FormError>}

View File

@@ -1,10 +1,8 @@
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="flex min-h-screen bg-gradient-radial from-slate-200 to-slate-50">{children}</div>
<div className="bg-gradient-radial flex min-h-screen from-slate-200 to-slate-50">{children}</div>
</div>
</div>
</>

View File

@@ -1,62 +1,63 @@
import { OTP } from "otplib";
import { Authenticator } from "@otplib/core";
import type { AuthenticatorOptions } from "@otplib/core/authenticator";
import { createDigest, createRandomBytes } from "@otplib/plugin-crypto";
import { keyDecoder, keyEncoder } from "@otplib/plugin-thirty-two";
import { describe, expect, test, vi } from "vitest";
import { totpAuthenticatorCheck } from "./totp";
vi.mock("otplib", () => ({
OTP: vi.fn(),
}));
vi.mock("@otplib/core");
vi.mock("@otplib/plugin-crypto");
vi.mock("@otplib/plugin-thirty-two");
describe("totpAuthenticatorCheck", () => {
const token = "123456";
const secret = "JBSWY3DPEHPK3PXP";
const opts = { window: [1, 0] as [number, number] };
const opts: Partial<AuthenticatorOptions> = { window: [1, 0] };
test("should check a TOTP token with a base32-encoded secret", () => {
const verifySyncMock = vi.fn().mockReturnValue({ valid: true });
(OTP as unknown as vi.Mock).mockImplementation(function OTP() {
return {
verifySync: verifySyncMock,
};
});
const checkMock = vi.fn().mockReturnValue(true);
(Authenticator as unknown as vi.Mock).mockImplementation(() => ({
check: checkMock,
}));
const result = totpAuthenticatorCheck(token, secret, opts);
expect(verifySyncMock).toHaveBeenCalledWith({
token,
secret,
period: 30,
epochTolerance: [30, 0],
expect(Authenticator).toHaveBeenCalledWith({
createDigest,
createRandomBytes,
keyDecoder,
keyEncoder,
window: [1, 0],
});
expect(checkMock).toHaveBeenCalledWith(token, secret);
expect(result).toBe(true);
});
test("should use default window if none is provided", () => {
const verifySyncMock = vi.fn().mockReturnValue({ valid: true });
(OTP as unknown as vi.Mock).mockImplementation(function OTP() {
return {
verifySync: verifySyncMock,
};
});
const checkMock = vi.fn().mockReturnValue(true);
(Authenticator as unknown as vi.Mock).mockImplementation(() => ({
check: checkMock,
}));
const result = totpAuthenticatorCheck(token, secret);
expect(verifySyncMock).toHaveBeenCalledWith({
token,
secret,
period: 30,
epochTolerance: [30, 0],
expect(Authenticator).toHaveBeenCalledWith({
createDigest,
createRandomBytes,
keyDecoder,
keyEncoder,
window: [1, 0],
});
expect(checkMock).toHaveBeenCalledWith(token, secret);
expect(result).toBe(true);
});
test("should throw an error for invalid token format", () => {
(OTP as unknown as vi.Mock).mockImplementation(function OTP() {
return {
verifySync: () => {
throw new Error("Invalid token format");
},
};
});
(Authenticator as unknown as vi.Mock).mockImplementation(() => ({
check: () => {
throw new Error("Invalid token format");
},
}));
expect(() => {
totpAuthenticatorCheck("invalidToken", secret);
@@ -64,13 +65,11 @@ describe("totpAuthenticatorCheck", () => {
});
test("should throw an error for invalid secret format", () => {
(OTP as unknown as vi.Mock).mockImplementation(function OTP() {
return {
verifySync: () => {
throw new Error("Invalid secret format");
},
};
});
(Authenticator as unknown as vi.Mock).mockImplementation(() => ({
check: () => {
throw new Error("Invalid secret format");
},
}));
expect(() => {
totpAuthenticatorCheck(token, "invalidSecret");
@@ -78,12 +77,10 @@ describe("totpAuthenticatorCheck", () => {
});
test("should return false if token verification fails", () => {
const verifySyncMock = vi.fn().mockReturnValue({ valid: false });
(OTP as unknown as vi.Mock).mockImplementation(function OTP() {
return {
verifySync: verifySyncMock,
};
});
const checkMock = vi.fn().mockReturnValue(false);
(Authenticator as unknown as vi.Mock).mockImplementation(() => ({
check: checkMock,
}));
const result = totpAuthenticatorCheck(token, secret);
expect(result).toBe(false);

View File

@@ -1,15 +1,7 @@
import { OTP, type OTPVerifyOptions } from "otplib";
type TOTPAuthenticatorOptions = {
window?: number | [number, number];
period?: OTPVerifyOptions["period"];
epoch?: OTPVerifyOptions["epoch"];
t0?: OTPVerifyOptions["t0"];
algorithm?: OTPVerifyOptions["algorithm"];
digits?: OTPVerifyOptions["digits"];
};
const createTotp = () => new OTP({ strategy: "totp" });
import { Authenticator } from "@otplib/core";
import type { AuthenticatorOptions } from "@otplib/core/authenticator";
import { createDigest, createRandomBytes } from "@otplib/plugin-crypto";
import { keyDecoder, keyEncoder } from "@otplib/plugin-thirty-two";
/**
* Checks the validity of a TOTP token using a base32-encoded secret.
@@ -22,19 +14,16 @@ const createTotp = () => new OTP({ strategy: "totp" });
export const totpAuthenticatorCheck = (
token: string,
secret: string,
opts: TOTPAuthenticatorOptions = {}
opts: Partial<AuthenticatorOptions> = {}
) => {
const { window = [1, 0], period = 30, ...rest } = opts;
const [pastWindow, futureWindow] = Array.isArray(window) ? window : [window, window];
const totp = createTotp();
const result = totp.verifySync({
token,
secret,
period,
epochTolerance: [pastWindow * period, futureWindow * period],
const { window = [1, 0], ...rest } = opts;
const authenticator = new Authenticator({
createDigest,
createRandomBytes,
keyDecoder,
keyEncoder,
window,
...rest,
});
return result.valid;
return authenticator.check(token, secret);
};

View File

@@ -21,15 +21,11 @@ import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/compon
import { PasswordInput } from "@/modules/ui/components/password-input";
const ZLoginForm = z.object({
email: z.email(),
email: z.string().email(),
password: z
.string()
.min(8, {
error: "Password must be at least 8 characters long",
})
.max(128, {
error: "Password must be 128 characters or less",
}),
.min(8, { message: "Password must be at least 8 characters long" })
.max(128, { message: "Password must be 128 characters or less" }),
totpCode: z.string().optional(),
backupCode: z.string().optional(),
});
@@ -188,7 +184,7 @@ export const LoginForm = ({
value={field.value}
onChange={(email) => field.onChange(email)}
placeholder="work@email.com"
className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
/>
{error?.message && <FormError className="text-left">{error.message}</FormError>}
</div>
@@ -211,7 +207,7 @@ export const LoginForm = ({
aria-label="password"
aria-required="true"
required
className="block w-full rounded-md border-slate-300 pr-8 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 pr-8 shadow-sm sm:text-sm"
value={field.value}
onChange={(password) => field.onChange(password)}
/>
@@ -225,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="text-xs text-slate-500 hover:text-brand-dark">
className="hover:text-brand-dark text-xs text-slate-500">
{t("auth.login.forgot_your_password")}
</Link>
</div>

View File

@@ -24,7 +24,7 @@ import { PasswordChecks } from "./password-checks";
const ZSignupInput = z.object({
name: ZUserName,
email: z.email(),
email: z.string().email(),
password: ZUserPassword,
});
@@ -222,7 +222,7 @@ export const SignupForm = ({
placeholder="*******"
aria-placeholder="password"
required
className="block w-full rounded-md shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md shadow-sm 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="flex min-h-screen bg-gradient-radial from-slate-200 to-slate-50">
<div className="bg-gradient-radial flex min-h-screen from-slate-200 to-slate-50">
<FormWrapper>
<EmailChangeSignIn token={token} />
<BackToLoginButton />

View File

@@ -2,9 +2,9 @@ import { z } from "zod";
export const ZRateLimitConfig = z.object({
/** Rate limit window in seconds */
interval: z.int().positive().describe("Rate limit window in seconds"),
interval: z.number().int().positive().describe("Rate limit window in seconds"),
/** Maximum allowed requests per interval */
allowedPerInterval: z.int().positive().describe("Maximum allowed requests per interval"),
allowedPerInterval: z.number().int().positive().describe("Maximum allowed requests per interval"),
/** Namespace for grouping rate limit per feature */
namespace: z.string().min(1).describe("Namespace for grouping rate limit per feature"),
});

View File

@@ -73,12 +73,12 @@ export const ZAuditLogEventSchema = z.object({
type: ZAuditTarget,
}),
status: ZAuditStatus,
timestamp: z.iso.datetime(),
timestamp: z.string().datetime(),
organizationId: z.string(),
ipAddress: z.string().optional(), // Not using the .ip() here because if we don't enabled it we want to put UNKNOWN_DATA string, to keep the same pattern as the other fields
changes: z.record(z.string(), z.any()).optional(),
changes: z.record(z.any()).optional(),
eventId: z.string().optional(),
apiUrl: z.url().optional(),
apiUrl: z.string().url().optional(),
});
export type TAuditLogEvent = z.infer<typeof ZAuditLogEventSchema>;

View File

@@ -16,7 +16,7 @@ import { isSubscriptionCancelled } from "@/modules/ee/billing/api/lib/is-subscri
const ZUpgradePlanAction = z.object({
environmentId: ZId,
priceLookupKey: z.enum(STRIPE_PRICE_LOOKUP_KEYS),
priceLookupKey: z.nativeEnum(STRIPE_PRICE_LOOKUP_KEYS),
});
export const upgradePlanAction = authenticatedActionClient.schema(ZUpgradePlanAction).action(

View File

@@ -8,8 +8,7 @@ import { getOrganization, updateOrganization } from "@/lib/organization/service"
export const handleInvoiceFinalized = async (event: Stripe.Event) => {
const invoice = event.data.object as Stripe.Invoice;
const subscription = invoice.parent?.subscription_details?.subscription;
const subscriptionId = typeof subscription === "string" ? subscription : subscription?.id;
const subscriptionId = invoice.subscription as string;
if (!subscriptionId) {
logger.warn({ invoiceId: invoice.id }, "Invoice finalized without subscription ID");
return { status: 400, message: "No subscription ID found in invoice" };

View File

@@ -34,11 +34,9 @@ export const isSubscriptionCancelled = async (
for (const subscription of subscriptions.data) {
if (subscription.cancel_at_period_end) {
const cancellationTimestamp =
subscription.cancel_at ?? subscription.ended_at ?? subscription.canceled_at;
return {
cancelled: true,
date: cancellationTimestamp ? new Date(cancellationTimestamp * 1000) : null,
date: new Date(subscription.current_period_end * 1000),
};
}
}

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="mb-3 mr-2 inline-flex w-full text-2xl font-bold text-slate-700">
<h2 className="mr-2 mb-3 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 && "mb-0 mt-4 flex-row pb-0"
peopleUnlimitedCheck && "mt-4 mb-0 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 && "mb-0 mt-4 flex-row pb-0"
projectsUnlimitedCheck && "mt-4 mb-0 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 whitespace-nowrap rounded-md py-0.5 pl-4 pr-2 text-center ${
className={`flex-1 items-center rounded-md py-0.5 pr-2 pl-4 text-center whitespace-nowrap ${
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: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"
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"
aria-hidden="true"
/>
{getCloudPricingData(t).plans.map((plan) => (

View File

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

View File

@@ -23,7 +23,7 @@ import {
const ZGetContactsAction = z.object({
environmentId: ZId,
offset: z.int().nonnegative(),
offset: z.number().int().nonnegative(),
searchValue: z.string().optional(),
});

View File

@@ -64,7 +64,7 @@ export const POST = withV1ApiWrapper({
{
environmentId: params.environmentId,
url: req.url,
validationError: cuidValidation.error.issues[0]?.message,
validationError: cuidValidation.error.errors[0]?.message,
},
"Invalid CUID v1 format detected"
);

View File

@@ -4,7 +4,7 @@ import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
export const ZContactAttributeKeyCreateInput = z.object({
key: z.string().refine((val) => isSafeIdentifier(val), {
error:
message:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
}),
description: z.string().optional(),
@@ -21,7 +21,7 @@ export const ZContactAttributeKeyUpdateInput = z.object({
key: z
.string()
.refine((val) => isSafeIdentifier(val), {
error:
message:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
})
.optional(),

View File

@@ -20,7 +20,7 @@ import {
const ZCreateContactAttributeKeyAction = z.object({
environmentId: ZId,
key: z.string().refine((val) => isSafeIdentifier(val), {
error:
message:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
}),
name: z.string().optional(),

View File

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

View File

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

View File

@@ -84,7 +84,7 @@ export const UploadContactsCSVButton = ({
const parsedRecords = ZContactCSVUploadResponse.safeParse(records);
if (!parsedRecords.success) {
console.error("Error parsing CSV:", parsedRecords.error);
setError(parsedRecords.error.issues[0].message);
setError(parsedRecords.error.errors[0].message);
return;
}

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-b-2 border-brand-dark font-semibold text-slate-900"
? "border-brand-dark border-b-2 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 whitespace-nowrap text-center text-sm text-slate-500 sm:block">
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-nowrap 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 whitespace-normal text-center text-sm text-slate-500 sm:block">
<div className="col-span-1 my-auto hidden text-center text-sm whitespace-normal 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 pl-2 pr-5">
<div className="flex items-center pr-5 pl-2">
<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

@@ -6,10 +6,10 @@ import {
} from "@formbricks/types/contact-attribute-key";
export const ZContact = z.object({
id: z.cuid2(),
id: z.string().cuid2(),
createdAt: z.date(),
updatedAt: z.date(),
environmentId: z.cuid2(),
environmentId: z.string().cuid2(),
});
const ZContactTableAttributeData = z.object({
@@ -29,7 +29,7 @@ export const ZContactTableData = z.object({
});
export const ZContactWithAttributes = ZContact.extend({
attributes: z.record(z.string(), z.string()),
attributes: z.record(z.string()),
});
export type TContactWithAttributes = z.infer<typeof ZContactWithAttributes>;
@@ -60,26 +60,22 @@ export const ZContactCSVDuplicateAction = z.enum(["skip", "update", "overwrite"]
export type TContactCSVDuplicateAction = z.infer<typeof ZContactCSVDuplicateAction>;
export const ZContactCSVUploadResponse = z
.array(z.record(z.string(), z.string()))
.max(10000, {
error: "Maximum 10000 records allowed at a time.",
})
.array(z.record(z.string()))
.max(10000, { message: "Maximum 10000 records allowed at a time." })
.superRefine((data, ctx) => {
for (const record of data) {
if (!Object.keys(record).includes("email")) {
ctx.addIssue({
code: "custom",
return ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Missing email field for one or more records",
});
return;
}
if (!record.email) {
ctx.addIssue({
code: "custom",
return ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Email field is empty for one or more records",
});
return;
}
}
@@ -88,11 +84,10 @@ export const ZContactCSVUploadResponse = z
const emailSet = new Set(emails);
if (emails.length !== emailSet.size) {
ctx.addIssue({
code: "custom",
return ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Duplicate emails found in the records",
});
return;
}
// check for duplicate userIds if present
@@ -100,11 +95,10 @@ export const ZContactCSVUploadResponse = z
if (userIds?.length > 0) {
const userIdSet = new Set(userIds);
if (userIds.length !== userIdSet.size) {
ctx.addIssue({
code: "custom",
return ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Duplicate userIds found in the records",
});
return;
}
}
});
@@ -115,11 +109,10 @@ export const ZContactCSVAttributeMap = z.record(z.string(), z.string()).superRef
const values = Object.values(attributeMap);
if (new Set(values).size !== values.length) {
ctx.addIssue({
code: "custom",
return ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Attribute map contains duplicate values",
});
return;
}
});
export type TContactCSVAttributeMap = z.infer<typeof ZContactCSVAttributeMap>;
@@ -149,17 +142,17 @@ export const validateEmailAttribute = (
if (!emailAttr?.value) {
ctx.addIssue({
code: "custom",
code: z.ZodIssueCode.custom,
message: `Email attribute is required${indexSuffix}`,
});
return { isValid: false };
}
// Check email format
const parsedEmail = z.email().safeParse(emailAttr.value);
const parsedEmail = z.string().email().safeParse(emailAttr.value);
if (!parsedEmail.success) {
ctx.addIssue({
code: "custom",
code: z.ZodIssueCode.custom,
message: `Invalid email format${indexSuffix}`,
});
return { emailAttr, isValid: false };
@@ -190,7 +183,7 @@ export const validateUniqueAttributeKeys = (
if (duplicateKeys.length > 0) {
const indexSuffix = contactIndex !== undefined ? ` for contact at index ${contactIndex}` : "";
ctx.addIssue({
code: "custom",
code: z.ZodIssueCode.custom,
message: `Duplicate attribute keys found${indexSuffix}. Please ensure each attribute key is unique`,
params: {
duplicateKeys,
@@ -201,12 +194,10 @@ export const validateUniqueAttributeKeys = (
};
export const ZContactBulkUploadRequest = z.object({
environmentId: z.cuid2(),
environmentId: z.string().cuid2(),
contacts: z
.array(ZContactBulkUploadContact)
.max(250, {
error: "Maximum 250 contacts allowed at a time.",
})
.max(250, { message: "Maximum 250 contacts allowed at a time." })
.superRefine((contacts, ctx) => {
// Track all data in a single pass
const seenEmails = new Set<string>();
@@ -245,7 +236,7 @@ export const ZContactBulkUploadRequest = z.object({
// Report all validation issues after the single pass
if (duplicateEmails.size > 0) {
ctx.addIssue({
code: "custom",
code: z.ZodIssueCode.custom,
message: "Duplicate emails found in the records, please ensure each email is unique.",
params: {
duplicateEmails: Array.from(duplicateEmails),
@@ -255,7 +246,7 @@ export const ZContactBulkUploadRequest = z.object({
if (duplicateUserIds.size > 0) {
ctx.addIssue({
code: "custom",
code: z.ZodIssueCode.custom,
message: "Duplicate userIds found in the records, please ensure each userId is unique.",
params: {
duplicateUserIds: Array.from(duplicateUserIds),
@@ -285,21 +276,21 @@ export type TContactBulkUploadResponseSuccess = TContactBulkUploadResponseBase &
// Schema for single contact creation - simplified with flat attributes
export const ZContactCreateRequest = z.object({
environmentId: z.cuid2(),
environmentId: z.string().cuid2(),
attributes: z.record(z.string(), z.string()).superRefine((attributes, ctx) => {
// Check if email attribute exists and is valid
const email = attributes.email;
if (!email) {
ctx.addIssue({
code: "custom",
code: z.ZodIssueCode.custom,
message: "Email attribute is required",
});
} else {
// Check email format
const parsedEmail = z.email().safeParse(email);
const parsedEmail = z.string().email().safeParse(email);
if (!parsedEmail.success) {
ctx.addIssue({
code: "custom",
code: z.ZodIssueCode.custom,
message: "Invalid email format",
});
}
@@ -311,9 +302,9 @@ export type TContactCreateRequest = z.infer<typeof ZContactCreateRequest>;
// Type for contact response with flattened attributes
export const ZContactResponse = z.object({
id: z.cuid2(),
id: z.string().cuid2(),
createdAt: z.date(),
environmentId: z.cuid2(),
environmentId: z.string().cuid2(),
attributes: z.record(z.string(), z.string()),
});
@@ -345,7 +336,7 @@ export const ZEditContactAttributesForm = z.object({
if (indices.length > 1) {
indices.forEach((index) => {
ctx.addIssue({
code: "custom",
code: z.ZodIssueCode.custom,
message: `Duplicate key: ${key}`,
path: [index, "key"],
});
@@ -367,13 +358,13 @@ export const ZEditContactAttributesForm = z.object({
// When both are empty, show "Either email or userId is required" on both fields
if (emailIndex !== -1 && userIdIndex !== -1) {
ctx.addIssue({
code: "custom",
code: z.ZodIssueCode.custom,
message: "Either email or userId is required",
path: [emailIndex, "value"],
});
ctx.addIssue({
code: "custom",
code: z.ZodIssueCode.custom,
message: "Either email or userId is required",
path: [userIdIndex, "value"],
});
@@ -383,10 +374,10 @@ export const ZEditContactAttributesForm = z.object({
// Validate email format if key is "email" and has a value
attributes.forEach((attr, index) => {
if (attr.key === "email" && attr.value && attr.value.trim() !== "") {
const emailResult = z.email().safeParse(attr.value);
const emailResult = z.string().email().safeParse(attr.value);
if (!emailResult.success) {
ctx.addIssue({
code: "custom",
code: z.ZodIssueCode.custom,
message: "Invalid email format",
path: [index, "value"],
});
@@ -425,7 +416,7 @@ export const createEditContactAttributesSchema = (
if (dataType === "date") {
if (!hasValue) {
ctx.addIssue({
code: "custom",
code: z.ZodIssueCode.custom,
message: t("environments.contacts.date_value_required"),
path: ["attributes", index, "value"],
});
@@ -435,7 +426,7 @@ export const createEditContactAttributesSchema = (
const date = new Date(attr.value);
if (Number.isNaN(date.getTime())) {
ctx.addIssue({
code: "custom",
code: z.ZodIssueCode.custom,
message: t("environments.contacts.invalid_date_format"),
path: ["attributes", index, "value"],
});
@@ -443,7 +434,7 @@ export const createEditContactAttributesSchema = (
} else if (dataType === "number") {
if (!hasValue) {
ctx.addIssue({
code: "custom",
code: z.ZodIssueCode.custom,
message: t("environments.contacts.number_value_required"),
path: ["attributes", index, "value"],
});
@@ -452,7 +443,7 @@ export const createEditContactAttributesSchema = (
// Validate number format
if (Number.isNaN(Number(attr.value))) {
ctx.addIssue({
code: "custom",
code: z.ZodIssueCode.custom,
message: t("environments.contacts.invalid_number_format"),
path: ["attributes", index, "value"],
});

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