mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-05 10:19:40 -06:00
Compare commits
1 Commits
codex/depe
...
fix/multi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce82dbd517 |
2
.husky/post-checkout
Normal file
2
.husky/post-checkout
Normal file
@@ -0,0 +1,2 @@
|
||||
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
|
||||
prettier --write ./branch.json
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
const baseConfig = require("../../.prettierrc.js");
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
tailwindConfig: "./tailwind.config.js",
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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")} />
|
||||
|
||||
|
||||
@@ -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={"/"}>
|
||||
|
||||
@@ -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={"/"}>
|
||||
|
||||
@@ -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={"/"}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZOrganizationTeam = z.object({
|
||||
id: z.cuid2(),
|
||||
id: z.string().cuid2(),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -21,7 +21,7 @@ export const getTeamsEndpoint: ZodOpenApiOperationObject = {
|
||||
path: z.object({
|
||||
organizationId: ZOrganizationIdSchema,
|
||||
}),
|
||||
query: ZGetTeamsFilter,
|
||||
query: ZGetTeamsFilter.sourceType(),
|
||||
},
|
||||
tags: ["Organizations API - Teams"],
|
||||
responses: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ export const getUsersEndpoint: ZodOpenApiOperationObject = {
|
||||
path: z.object({
|
||||
organizationId: ZOrganizationIdSchema,
|
||||
}),
|
||||
query: ZGetUsersFilter,
|
||||
query: ZGetUsersFilter.sourceType(),
|
||||
},
|
||||
tags: ["Organizations API - Users"],
|
||||
responses: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
|
||||
const ZGetContactsAction = z.object({
|
||||
environmentId: ZId,
|
||||
offset: z.int().nonnegative(),
|
||||
offset: z.number().int().nonnegative(),
|
||||
searchValue: z.string().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user