Compare commits

...

21 Commits

Author SHA1 Message Date
Dhruwang ac208e8722 fix: lazy load surveys translations 2026-03-09 19:29:02 +05:30
Anshuman Pandey 96f173c3b1 fix: overrides packages for CVE fixes (#7442) 2026-03-09 09:55:02 +00:00
Harsh Bhat 9c9e55fba6 docs: add keycloack docs (#7440) 2026-03-09 08:38:00 +00:00
Johannes 42541f86fd feat(navigation): add workflows section to main navigation and update… (#7392) 2026-03-08 18:13:38 +00:00
Matti Nannt 0ba469a73d fix: pin fast-xml-parser to 5.3.5 (#7436) 2026-03-06 20:20:34 +01:00
Matti Nannt afa192e5b9 chore: upgrade deps and Zod v4 migration (#7425)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-06 14:41:28 +01:00
Bhagya Amarasinghe 4860a9a5cf fix: helm template duplicate label key in migration-job (#7431)
Co-authored-by: Rob <178471500+rob-htl@users.noreply.github.com>
2026-03-06 11:48:07 +00:00
Chowdhury Tafsir Ahmed Siddiki af02ce9ea6 fix: display native language names in profile language selector (#7349)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-06 10:18:52 +00:00
Bhagya Amarasinghe fc1c91896a fix: add server-side SSRF validation for webhook URLs (#7414)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-06 07:36:49 +00:00
Balázs Úr f5c7dbdc71 fix: mark duplicated survey name as translatable (#7379)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-06 06:37:05 +00:00
Balázs Úr b88ea5cc66 fix: use proper plural forms (#7322)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-06 06:30:27 +00:00
bharath kumar f31085a9e7 fix(i18n): resolve duplicate Hungarian translations causing Career Development Survey creation to fail (#7410)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-06 05:39:05 +00:00
Dhruwang Jariwala 2ab0441404 fix: z-index for multi select question with dropdwon display type (#7420) 2026-03-06 04:56:39 +00:00
Dhruwang Jariwala 299ae81b21 chore: mls tweaks (#7416) 2026-03-05 14:55:45 +00:00
Bhagya Amarasinghe f73f13f16c perf: fix Prisma connection pool saturation from unbounded Promise.all fan-outs (#7404) 2026-03-05 14:35:40 +00:00
Matti Nannt e9bcbf6e4c fix: patch @isaacs/brace-expansion to 5.0.1 (#7424) 2026-03-05 13:35:48 +00:00
Matti Nannt 32eda35a71 chore: clean up stale turbo task config (#7423) 2026-03-05 11:49:24 +00:00
Dhruwang Jariwala 84999cddfd feat: danish support to surveys package (#7415) 2026-03-05 11:05:40 +00:00
Matti Nannt f0a0cf531a chore: clean up unused npm dependencies (#7417)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-05 10:48:13 +00:00
Matti Nannt f3e02fa466 chore: optimize monorepo build performance (#7419) 2026-03-05 10:18:54 +00:00
Dhruwang Jariwala f0a93ae092 fix: add Tailwind v3 config for Prettier in apps/web and packages/email (#7421) 2026-03-05 10:05:05 +00:00
405 changed files with 8252 additions and 6561 deletions
+6 -11
View File
@@ -10,25 +10,20 @@
"build-storybook": "storybook build",
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"@formbricks/survey-ui": "workspace:*"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "10.2.14",
"@storybook/addon-links": "10.2.14",
"@storybook/addon-onboarding": "10.2.14",
"@storybook/react-vite": "10.2.14",
"@storybook/addon-a11y": "10.2.15",
"@storybook/addon-links": "10.2.15",
"@storybook/addon-onboarding": "10.2.15",
"@storybook/react-vite": "10.2.15",
"@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",
"prop-types": "15.8.1",
"storybook": "10.2.14",
"storybook": "10.2.15",
"vite": "7.3.1",
"@storybook/addon-docs": "10.2.14"
"@storybook/addon-docs": "10.2.15"
}
}
+6
View File
@@ -0,0 +1,6 @@
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="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-slate-400 opacity-75"></span>
<span className="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-slate-400 opacity-75"></span>
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
</span>
<p className="pt-4 text-sm font-medium text-slate-600">
@@ -46,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel}
/>
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}`}>
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}/surveys`}>
@@ -42,7 +42,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
return (
<aside
className={cn(
"w-sidebar-collapsed z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
)}>
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>
@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>
@@ -69,7 +69,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/>
{projects.length >= 1 && (
<Button
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>
@@ -1,7 +1,7 @@
import { z } from "zod";
export const ZOrganizationTeam = z.object({
id: z.string().cuid2(),
id: z.cuid2(),
name: z.string(),
});
@@ -25,7 +25,7 @@ const ZCreateProjectAction = z.object({
data: ZProjectUpdateInput,
});
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
export const createProjectAction = authenticatedActionClient.inputSchema(ZCreateProjectAction).action(
withAuditLogging(
"created",
"project",
@@ -97,7 +97,7 @@ const ZGetOrganizationsForSwitcherAction = z.object({
* Called on-demand when user opens the organization switcher.
*/
export const getOrganizationsForSwitcherAction = authenticatedActionClient
.schema(ZGetOrganizationsForSwitcherAction)
.inputSchema(ZGetOrganizationsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -122,7 +122,7 @@ const ZGetProjectsForSwitcherAction = z.object({
* Called on-demand when user opens the project switcher.
*/
export const getProjectsForSwitcherAction = authenticatedActionClient
.schema(ZGetProjectsForSwitcherAction)
.inputSchema(ZGetProjectsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -11,6 +11,7 @@ import {
RocketIcon,
UserCircleIcon,
UserIcon,
WorkflowIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
@@ -114,6 +115,13 @@ export const MainNavigation = ({
pathname?.includes("/segments") ||
pathname?.includes("/attributes"),
},
{
name: t("common.workflows"),
href: `/environments/${environment.id}/workflows`,
icon: WorkflowIcon,
isActive: pathname?.includes("/workflows"),
isHidden: !isFormbricksCloud,
},
{
name: t("common.configuration"),
href: `/environments/${environment.id}/workspace/general`,
@@ -121,7 +129,7 @@ export const MainNavigation = ({
isActive: pathname?.includes("/project"),
},
],
[t, environment.id, pathname]
[t, environment.id, pathname, isFormbricksCloud]
);
const dropdownNavigation = [
@@ -188,7 +196,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />
@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
<currentStatus.icon />
</div>
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
{status === "notImplemented" && (
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
<RotateCcwIcon />
@@ -12,7 +12,7 @@ const ZUpdateNotificationSettingsAction = z.object({
});
export const updateNotificationSettingsAction = authenticatedActionClient
.schema(ZUpdateNotificationSettingsAction)
.inputSchema(ZUpdateNotificationSettingsAction)
.action(
withAuditLogging(
"updated",
@@ -63,7 +63,7 @@ async function handleEmailUpdate({
return payload;
}
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPersonalInfoUpdateInput).action(
withAuditLogging(
"updated",
"user",
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages } from "@/lib/i18n/utils";
import { appLanguages, sortedAppLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { Button } from "@/modules/ui/components/button";
@@ -198,41 +198,54 @@ export const EditProfileDetailsForm = ({
<FormField
control={form.control}
name="locale"
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-10 w-full border border-slate-300 px-3 text-left">
<div className="flex w-full items-center justify-between">
{appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"}
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{appLanguages.map((lang) => (
<DropdownMenuRadioItem
key={lang.code}
value={lang.code}
className="min-h-8 cursor-pointer">
{lang.label["en-US"]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormError />
</FormItem>
)}
render={({ field }) => {
const selectedLanguage = appLanguages.find((l) => l.code === field.value);
return (
<FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-10 w-full border border-slate-300 px-3 text-left">
<div className="flex w-full items-center justify-between">
{selectedLanguage ? (
<>
{selectedLanguage.label["en-US"]}
{selectedLanguage.label.native !== selectedLanguage.label["en-US"] &&
` (${selectedLanguage.label.native})`}
</>
) : (
t("common.select")
)}
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{sortedAppLanguages.map((lang) => (
<DropdownMenuRadioItem
key={lang.code}
value={lang.code}
className="min-h-8 cursor-pointer">
{lang.label["en-US"]}
{lang.label.native !== lang.label["en-US"] && ` (${lang.label.native})`}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormError />
</FormItem>
);
}}
/>
{isPasswordResetEnabled && (
@@ -98,7 +98,7 @@ export const PasswordConfirmationModal = ({
aria-label="password"
aria-required="true"
required
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
value={field.value}
onChange={(password) => field.onChange(password)}
/>
@@ -17,7 +17,7 @@ const ZUpdateOrganizationNameAction = z.object({
});
export const updateOrganizationNameAction = authenticatedActionClient
.schema(ZUpdateOrganizationNameAction)
.inputSchema(ZUpdateOrganizationNameAction)
.action(
withAuditLogging(
"updated",
@@ -55,28 +55,36 @@ const ZDeleteOrganizationAction = z.object({
organizationId: ZId,
});
export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action(
withAuditLogging(
"deleted",
"organization",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
export const deleteOrganizationAction = authenticatedActionClient
.inputSchema(ZDeleteOrganizationAction)
.action(
withAuditLogging(
"deleted",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId);
}
)
);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId);
}
)
);
@@ -23,7 +23,7 @@ const ZGetResponsesAction = z.object({
});
export const getResponsesAction = authenticatedActionClient
.schema(ZGetResponsesAction)
.inputSchema(ZGetResponsesAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -57,7 +57,7 @@ const ZGetSurveySummaryAction = z.object({
});
export const getSurveySummaryAction = authenticatedActionClient
.schema(ZGetSurveySummaryAction)
.inputSchema(ZGetSurveySummaryAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -85,7 +85,7 @@ const ZGetResponseCountAction = z.object({
});
export const getResponseCountAction = authenticatedActionClient
.schema(ZGetResponseCountAction)
.inputSchema(ZGetResponseCountAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -110,12 +110,12 @@ export const getResponseCountAction = authenticatedActionClient
const ZGetDisplaysWithContactAction = z.object({
surveyId: ZId,
limit: z.number().int().min(1).max(100),
offset: z.number().int().nonnegative(),
limit: z.int().min(1).max(100),
offset: z.int().nonnegative(),
});
export const getDisplaysWithContactAction = authenticatedActionClient
.schema(ZGetDisplaysWithContactAction)
.inputSchema(ZGetDisplaysWithContactAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -3,6 +3,7 @@ import { getServerSession } from "next-auth";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
type Props = {
@@ -14,10 +15,11 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
const session = await getServerSession(authOptions);
const survey = await getSurvey(params.surveyId);
const responseCount = await getResponseCountBySurveyId(params.surveyId);
const t = await getTranslate();
if (session) {
return {
title: `${responseCount} Responses | ${survey?.name} Results`,
title: `${t("common.count_responses", { count: responseCount })} | ${t("environments.surveys.summary.survey_results", { surveyName: survey?.name })}`,
};
}
return {
@@ -22,7 +22,7 @@ const ZSendEmbedSurveyPreviewEmailAction = z.object({
});
export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
.schema(ZSendEmbedSurveyPreviewEmailAction)
.inputSchema(ZSendEmbedSurveyPreviewEmailAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
@@ -69,7 +69,7 @@ const ZResetSurveyAction = z.object({
projectId: ZId,
});
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
withAuditLogging(
"updated",
"survey",
@@ -123,7 +123,7 @@ const ZGetEmailHtmlAction = z.object({
});
export const getEmailHtmlAction = authenticatedActionClient
.schema(ZGetEmailHtmlAction)
.inputSchema(ZGetEmailHtmlAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -152,7 +152,7 @@ const ZGeneratePersonalLinksAction = z.object({
});
export const generatePersonalLinksAction = authenticatedActionClient
.schema(ZGeneratePersonalLinksAction)
.inputSchema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
@@ -231,7 +231,7 @@ const ZUpdateSingleUseLinksAction = z.object({
});
export const updateSingleUseLinksAction = authenticatedActionClient
.schema(ZUpdateSingleUseLinksAction)
.inputSchema(ZUpdateSingleUseLinksAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -30,8 +30,7 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary.booked.count}{" "}
{elementSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
{t("common.count_responses", { count: elementSummary.booked.count })}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} />
@@ -47,8 +46,7 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary.skipped.count}{" "}
{elementSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
{t("common.count_responses", { count: elementSummary.skipped.count })}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} />
@@ -64,7 +64,7 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{summaryItem.count} {summaryItem.count === 1 ? t("common.response") : t("common.responses")}
{t("common.count_responses", { count: summaryItem.count })}
</p>
</div>
<div className="group-hover:opacity-80">
@@ -48,7 +48,7 @@ export const ElementSummaryHeader = ({
{showResponses && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.responseCount} ${t("common.responses")}`}
{t("common.count_responses", { count: elementSummary.responseCount })}
</div>
)}
{additionalInfo}
@@ -41,8 +41,7 @@ export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: Hid
</div>
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{elementSummary.responseCount}{" "}
{elementSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
{t("common.count_responses", { count: elementSummary.responseCount })}
</div>
</div>
</div>
@@ -31,7 +31,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
if (label) {
return label;
} else if (percentage !== undefined && totalResponsesForRow !== undefined) {
return `${Math.round((percentage / 100) * totalResponsesForRow)} ${t("common.responses")}`;
return t("common.count_responses", { count: Math.round((percentage / 100) * totalResponsesForRow) });
}
return "";
};
@@ -77,7 +77,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
)}>
<button
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
className="m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline hover:outline-brand-dark"
onClick={() =>
setFilter(
elementSummary.element.id,
@@ -75,7 +75,7 @@ export const MultipleChoiceSummary = ({
elementSummary.type === "multipleChoiceMulti" ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.selectionCount} ${t("common.selections")}`}
{t("common.count_selections", { count: elementSummary.selectionCount })}
</div>
) : undefined
}
@@ -110,7 +110,7 @@ export const MultipleChoiceSummary = ({
</div>
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
{t("common.count_selections", { count: result.count })}
</p>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
@@ -123,8 +123,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary[group]?.count}{" "}
{elementSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
{t("common.count_responses", { count: elementSummary[group]?.count })}
</p>
</div>
<ProgressBar
@@ -158,7 +157,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
}>
<div className="flex h-32 w-full flex-col items-center justify-end">
<div
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
className="w-full rounded-t-lg border border-slate-200 bg-brand-dark transition-all group-hover:brightness-110"
style={{
height: `${Math.max(choice.percentage, 2)}%`,
opacity,
@@ -37,7 +37,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
elementSummary.element.allowMulti ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.selectionCount} ${t("common.selections")}`}
{t("common.count_selections", { count: elementSummary.selectionCount })}
</div>
) : undefined
}
@@ -74,7 +74,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
</div>
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
{t("common.count_selections", { count: result.count })}
</p>
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
@@ -116,7 +116,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
)
}>
<div
className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
style={{ opacity }}
/>
</ClickableBarSegment>
@@ -198,7 +198,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
{t("common.count_responses", { count: result.count })}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
@@ -215,8 +215,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
<div className="text flex justify-between px-2">
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary.dismissed.count}{" "}
{elementSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
{t("common.count_responses", { count: elementSummary.dismissed.count })}
</p>
</div>
</div>
@@ -105,7 +105,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
<div className={scriptsMode === "replace" ? "opacity-50" : ""}>
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
<pre className="font-mono text-xs whitespace-pre-wrap text-slate-600">
<pre className="whitespace-pre-wrap font-mono text-xs text-slate-600">
{projectCustomScripts}
</pre>
</div>
@@ -135,7 +135,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
rows={8}
placeholder={t("environments.surveys.share.custom_html.placeholder")}
className={cn(
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
"flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
)}
{...field}
disabled={isReadOnly}
@@ -66,7 +66,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.use_personal_links")}
<Badge size="normal" type="success" className="absolute top-3 right-3" text={t("common.new")} />
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
</button>
<Link
href={`/environments/${environmentId}/settings/notifications`}
@@ -1095,7 +1095,7 @@ export const getResponsesForSummary = reactCache(
[limit, ZOptionalNumber],
[offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()],
[cursor, z.string().cuid2().optional()]
[cursor, z.cuid2().optional()]
);
const queryLimit = limit ?? RESPONSES_PER_PAGE;
@@ -28,7 +28,7 @@ const ZGetResponsesDownloadUrlAction = z.object({
});
export const getResponsesDownloadUrlAction = authenticatedActionClient
.schema(ZGetResponsesDownloadUrlAction)
.inputSchema(ZGetResponsesDownloadUrlAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -58,7 +58,7 @@ const ZGetSurveyFilterDataAction = z.object({
});
export const getSurveyFilterDataAction = authenticatedActionClient
.schema(ZGetSurveyFilterDataAction)
.inputSchema(ZGetSurveyFilterDataAction)
.action(async ({ ctx, parsedInput }) => {
const survey = await getSurvey(parsedInput.surveyId);
@@ -121,7 +121,7 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
}
};
export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action(
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
withAuditLogging(
"updated",
"survey",
@@ -192,7 +192,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
value={inputValue}
onValueChange={setInputValue}
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none ring-offset-transparent outline-none focus:border-none focus:shadow-none focus:ring-offset-0 focus:outline-none"
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
/>
)}
<Button
@@ -0,0 +1,208 @@
"use client";
import { CheckCircle2, Sparkles } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
const FORMBRICKS_HOST = "https://app.formbricks.com";
const SURVEY_ID = "cr9r4b2r73x6hlmn5aa2ha44";
const ENVIRONMENT_ID = "cmk41i8bi92bdad01svi74dec";
interface WorkflowsPageProps {
userEmail: string;
organizationName: string;
billingPlan: string;
}
type Step = "prompt" | "followup" | "thankyou";
export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: WorkflowsPageProps) => {
const { t } = useTranslation();
const [step, setStep] = useState<Step>("prompt");
const [promptValue, setPromptValue] = useState("");
const [detailsValue, setDetailsValue] = useState("");
const [responseId, setResponseId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleGenerateWorkflow = async () => {
if (promptValue.trim().length < 100 || isSubmitting) return;
setIsSubmitting(true);
try {
const res = await fetch(`${FORMBRICKS_HOST}/api/v2/client/${ENVIRONMENT_ID}/responses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
surveyId: SURVEY_ID,
finished: false,
data: {
workflow: promptValue.trim(),
useremail: userEmail,
orgname: organizationName,
billingplan: billingPlan,
},
}),
});
if (res.ok) {
const json = await res.json();
setResponseId(json.data?.id ?? null);
}
setStep("followup");
} catch {
setStep("followup");
} finally {
setIsSubmitting(false);
}
};
const handleSubmitFeedback = async () => {
if (isSubmitting) return;
setIsSubmitting(true);
if (responseId) {
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {
details: detailsValue.trim(),
},
}),
});
} catch {
// silently fail
}
}
setIsSubmitting(false);
setStep("thankyou");
};
const handleSkipFeedback = async () => {
if (!responseId) {
setStep("thankyou");
return;
}
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {},
}),
});
} catch {
// silently fail
}
setStep("thankyou");
};
if (step === "prompt") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="from-brand-light to-brand-dark mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br shadow-md">
<Sparkles className="h-6 w-6 text-white" />
</div>
<h1 className="text-4xl font-bold tracking-tight text-slate-800">{t("workflows.heading")}</h1>
<p className="text-lg text-slate-500">{t("workflows.subheading")}</p>
</div>
<div className="relative">
<textarea
value={promptValue}
onChange={(e) => setPromptValue(e.target.value)}
placeholder={t("workflows.placeholder")}
rows={5}
className="focus:border-brand-dark focus:ring-brand-light/20 w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:outline-none focus:ring-2"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleGenerateWorkflow();
}
}}
/>
<div className="mt-3 flex items-center justify-between">
<span
className={`text-xs ${promptValue.trim().length >= 100 ? "text-slate-400" : "text-amber-500"}`}>
{promptValue.trim().length} / 100
</span>
<Button
onClick={handleGenerateWorkflow}
disabled={promptValue.trim().length < 100 || isSubmitting}
loading={isSubmitting}
size="lg">
<Sparkles className="h-4 w-4" />
{t("workflows.generate_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
if (step === "followup") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
<Sparkles className="text-brand-dark h-6 w-6" />
</div>
<h1 className="text-3xl font-bold tracking-tight text-slate-800">
{t("workflows.coming_soon_title")}
</h1>
<p className="mx-auto max-w-md text-base text-slate-500">
{t("workflows.coming_soon_description")}
</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<label className="text-md mb-2 block font-medium text-slate-700">
{t("workflows.follow_up_label")}
</label>
<textarea
value={detailsValue}
onChange={(e) => setDetailsValue(e.target.value)}
placeholder={t("workflows.follow_up_placeholder")}
rows={4}
className="focus:border-brand-dark focus:ring-brand-light/20 w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:bg-white focus:outline-none focus:ring-2"
/>
<div className="mt-4 flex items-center justify-end gap-3">
<Button variant="ghost" onClick={handleSkipFeedback} className="text-slate-500">
{t("common.skip")}
</Button>
<Button
onClick={handleSubmitFeedback}
disabled={!detailsValue.trim() || isSubmitting}
loading={isSubmitting}>
{t("workflows.submit_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-md space-y-6 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
<CheckCircle2 className="h-8 w-8 text-green-500" />
</div>
<h1 className="text-2xl font-bold text-slate-800">{t("workflows.thank_you_title")}</h1>
<p className="text-base text-slate-500">{t("workflows.thank_you_description")}</p>
</div>
</div>
);
};
@@ -0,0 +1,39 @@
import { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { WorkflowsPage } from "./components/workflows-page";
export const metadata: Metadata = {
title: "Workflows",
};
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
if (!IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { session, organization, isBilling } = await getEnvironmentAuth(params.environmentId);
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
const user = await getUser(session.user.id);
if (!user) {
return redirect("/auth/login");
}
return (
<WorkflowsPage
userEmail={user.email}
organizationName={organization.name}
billingPlan={organization.billing.plan}
/>
);
};
export default Page;
@@ -21,7 +21,7 @@ const ZCreateOrUpdateIntegrationAction = z.object({
});
export const createOrUpdateIntegrationAction = authenticatedActionClient
.schema(ZCreateOrUpdateIntegrationAction)
.inputSchema(ZCreateOrUpdateIntegrationAction)
.action(
withAuditLogging(
"createdUpdated",
@@ -67,7 +67,7 @@ const ZDeleteIntegrationAction = z.object({
integrationId: ZId,
});
export const deleteIntegrationAction = authenticatedActionClient.schema(ZDeleteIntegrationAction).action(
export const deleteIntegrationAction = authenticatedActionClient.inputSchema(ZDeleteIntegrationAction).action(
withAuditLogging(
"deleted",
"integration",
@@ -17,7 +17,7 @@ const ZValidateGoogleSheetsConnectionAction = z.object({
});
export const validateGoogleSheetsConnectionAction = authenticatedActionClient
.schema(ZValidateGoogleSheetsConnectionAction)
.inputSchema(ZValidateGoogleSheetsConnectionAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -51,7 +51,7 @@ const ZGetSpreadsheetNameByIdAction = z.object({
});
export const getSpreadsheetNameByIdAction = authenticatedActionClient
.schema(ZGetSpreadsheetNameByIdAction)
.inputSchema(ZGetSpreadsheetNameByIdAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -10,7 +10,7 @@ const Loading = () => {
<div className="mt-6 p-6">
<GoBackButton />
<div className="mb-6 text-right">
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
{t("environments.integrations.google_sheets.link_new_sheet")}
</Button>
</div>
@@ -51,7 +51,7 @@ const Loading = () => {
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
</div>
<div className="text-center"></div>
@@ -10,7 +10,7 @@ const Loading = () => {
<div className="mt-6 p-6">
<GoBackButton />
<div className="mb-6 text-right">
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
{t("environments.integrations.notion.link_database")}
</Button>
</div>
@@ -48,7 +48,7 @@ const Loading = () => {
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
</div>
<div className="text-center"></div>
@@ -12,7 +12,7 @@ const ZGetSlackChannelsAction = z.object({
});
export const getSlackChannelsAction = authenticatedActionClient
.schema(ZGetSlackChannelsAction)
.inputSchema(ZGetSlackChannelsAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
+12 -7
View File
@@ -15,6 +15,7 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { sendResponseFinishedEmail } from "@/modules/email";
@@ -135,13 +136,17 @@ export const POST = async (request: Request) => {
);
}
return fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
}).catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
});
return validateWebhookUrl(webhook.url)
.then(() =>
fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
})
)
.catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
});
});
if (event === "responseFinished") {
@@ -50,7 +50,7 @@ export const GET = withV1ApiWrapper({
{
environmentId: params.environmentId,
url: req.url,
validationError: cuidValidation.error.errors[0]?.message,
validationError: cuidValidation.error.issues[0]?.message,
},
"Invalid CUID v1 format detected"
);
+112 -114
View File
@@ -6,140 +6,138 @@ export const GET = async (req: NextRequest) => {
let brandColor = req.nextUrl.searchParams.get("brandColor");
return new ImageResponse(
(
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
alignItems: "center",
backgroundColor: brandColor ? brandColor + "BF" : "#0000BFBF", // /75 opacity is approximately BF in hex
borderRadius: "0.75rem",
}}>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
alignItems: "center",
backgroundColor: brandColor ? brandColor + "BF" : "#0000BFBF", // /75 opacity is approximately BF in hex
width: "80%",
height: "60%",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "3.25rem",
position: "absolute",
left: "3rem",
top: "0.75rem",
opacity: 0.2,
transform: "rotate(356deg)",
}}></div>
<div
style={{
display: "flex",
flexDirection: "column",
width: "84%",
height: "60%",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "3rem",
position: "absolute",
top: "1.25rem",
left: "3.25rem",
borderWidth: "2px",
opacity: 0.6,
transform: "rotate(357deg)",
}}></div>
<div
style={{
display: "flex",
flexDirection: "column",
width: "85%",
height: "67%",
alignItems: "center",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "2rem",
position: "absolute",
top: "2.3rem",
left: "3.5rem",
transform: "rotate(360deg)",
}}>
<div
style={{
display: "flex",
flexDirection: "column",
width: "80%",
height: "60%",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "3.25rem",
position: "absolute",
left: "3rem",
top: "0.75rem",
opacity: 0.2,
transform: "rotate(356deg)",
}}></div>
<div
style={{
display: "flex",
flexDirection: "column",
width: "84%",
height: "60%",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "3rem",
position: "absolute",
top: "1.25rem",
left: "3.25rem",
borderWidth: "2px",
opacity: 0.6,
transform: "rotate(357deg)",
}}></div>
<div
style={{
display: "flex",
flexDirection: "column",
width: "85%",
height: "67%",
alignItems: "center",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "2rem",
position: "absolute",
top: "2.3rem",
left: "3.5rem",
transform: "rotate(360deg)",
}}>
<div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
<div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
justifyContent: "space-between",
}}>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
justifyContent: "space-between",
paddingLeft: "2rem",
paddingRight: "2rem",
}}>
<h2
style={{
display: "flex",
flexDirection: "column",
fontSize: "2rem",
fontWeight: "700",
letterSpacing: "-0.025em",
color: "#0f172a",
textAlign: "left",
marginTop: "3.75rem",
}}>
{name}
</h2>
</div>
</div>
<div style={{ display: "flex", justifyContent: "flex-end", marginRight: "2.5rem" }}>
<div
style={{
display: "flex",
borderRadius: "1rem",
position: "absolute",
right: "-0.5rem",
marginTop: "0.5rem",
}}>
<div
content=""
style={{
borderRadius: "0.75rem",
border: "1px solid transparent",
backgroundColor: brandColor ?? "#000",
height: "4.5rem",
width: "9.5rem",
opacity: 0.5,
}}></div>
</div>
<div
style={{
display: "flex",
borderRadius: "1rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
}}>
<div
style={{
display: "flex",
flexDirection: "column",
paddingLeft: "2rem",
paddingRight: "2rem",
alignItems: "center",
justifyContent: "center",
borderRadius: "0.75rem",
border: "1px solid transparent",
backgroundColor: brandColor ?? "#000",
fontSize: "1.5rem",
color: "white",
height: "4.5rem",
width: "9.5rem",
}}>
<h2
style={{
display: "flex",
flexDirection: "column",
fontSize: "2rem",
fontWeight: "700",
letterSpacing: "-0.025em",
color: "#0f172a",
textAlign: "left",
marginTop: "3.75rem",
}}>
{name}
</h2>
</div>
</div>
<div style={{ display: "flex", justifyContent: "flex-end", marginRight: "2.5rem" }}>
<div
style={{
display: "flex",
borderRadius: "1rem",
position: "absolute",
right: "-0.5rem",
marginTop: "0.5rem",
}}>
<div
content=""
style={{
borderRadius: "0.75rem",
border: "1px solid transparent",
backgroundColor: brandColor ?? "#000",
height: "4.5rem",
width: "9.5rem",
opacity: 0.5,
}}></div>
</div>
<div
style={{
display: "flex",
borderRadius: "1rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
}}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "0.75rem",
border: "1px solid transparent",
backgroundColor: brandColor ?? "#000",
fontSize: "1.5rem",
color: "white",
height: "4.5rem",
width: "9.5rem",
}}>
Begin!
</div>
Begin!
</div>
</div>
</div>
</div>
</div>
),
</div>,
{
width: 800,
height: 400,
@@ -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.string().cuid2()]);
validateInputs([surveyId, z.cuid2()]);
try {
const deletedSurvey = await prisma.survey.delete({
@@ -2,10 +2,11 @@ import { Prisma, WebhookSource } from "@prisma/client";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
import { createWebhook } from "@/app/api/v1/webhooks/lib/webhook";
import { TWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { validateInputs } from "@/lib/utils/validate";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -23,6 +24,10 @@ vi.mock("@/lib/crypto", () => ({
generateWebhookSecret: vi.fn(() => "whsec_test_secret_1234567890"),
}));
vi.mock("@/lib/utils/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn().mockResolvedValue(undefined),
}));
describe("createWebhook", () => {
afterEach(() => {
cleanup();
@@ -75,6 +80,41 @@ describe("createWebhook", () => {
expect(result).toEqual(createdWebhook);
});
test("should call validateWebhookUrl with the provided URL", async () => {
const webhookInput: TWebhookInput = {
environmentId: "test-env-id",
name: "Test Webhook",
url: "https://example.com",
source: "user",
triggers: ["responseCreated"],
surveyIds: ["survey1"],
};
vi.mocked(prisma.webhook.create).mockResolvedValueOnce({} as any);
await createWebhook(webhookInput);
expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com");
});
test("should throw InvalidInputError and skip Prisma create when URL fails SSRF validation", async () => {
const webhookInput: TWebhookInput = {
environmentId: "test-env-id",
name: "Test Webhook",
url: "http://169.254.169.254/latest/meta-data/",
source: "user",
triggers: ["responseCreated"],
surveyIds: ["survey1"],
};
vi.mocked(validateWebhookUrl).mockRejectedValueOnce(
new InvalidInputError("Webhook URL must not point to private or internal IP addresses")
);
await expect(createWebhook(webhookInput)).rejects.toThrow(InvalidInputError);
expect(prisma.webhook.create).not.toHaveBeenCalled();
});
test("should throw a ValidationError if the input data does not match the ZWebhookInput schema", async () => {
const invalidWebhookInput = {
environmentId: "test-env-id",
@@ -6,9 +6,11 @@ import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhoo
import { ITEMS_PER_PAGE } from "@/lib/constants";
import { generateWebhookSecret } from "@/lib/crypto";
import { validateInputs } from "@/lib/utils/validate";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
validateInputs([webhookInput, ZWebhookInput]);
await validateWebhookUrl(webhookInput.url);
try {
const secret = generateWebhookSecret();
@@ -101,7 +101,9 @@ describe("verifyRecaptchaToken", () => {
},
signal: {},
};
vi.spyOn(global, "AbortController").mockImplementation(() => abortController as any);
vi.spyOn(global, "AbortController").mockImplementation(function AbortController() {
return abortController as any;
});
(global.fetch as any).mockImplementation(() => new Promise(() => {}));
verifyRecaptchaToken("token", 0.5);
vi.advanceTimersByTime(5000);
+30 -45
View File
@@ -131,13 +131,11 @@ describe("withV1ApiWrapper", () => {
});
test("logs and audits on error response with API key authentication", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -185,13 +183,11 @@ describe("withV1ApiWrapper", () => {
});
test("does not log Sentry if not 500", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -233,13 +229,11 @@ describe("withV1ApiWrapper", () => {
});
test("logs and audits on thrown error", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -291,13 +285,11 @@ describe("withV1ApiWrapper", () => {
});
test("does not log on success response but still audits", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -347,13 +339,11 @@ describe("withV1ApiWrapper", () => {
REDIS_URL: "redis://localhost:6379",
}));
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { withV1ApiWrapper } = await import("./with-api-logging");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
@@ -376,9 +366,8 @@ describe("withV1ApiWrapper", () => {
});
test("handles client-side API routes without authentication", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
@@ -410,9 +399,8 @@ describe("withV1ApiWrapper", () => {
});
test("returns authentication error for non-client routes without auth", async () => {
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
@@ -435,9 +423,8 @@ describe("withV1ApiWrapper", () => {
test("handles rate limiting errors", async () => {
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
const { authenticateRequest } = await import("@/app/api/v1/auth");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
@@ -462,13 +449,11 @@ describe("withV1ApiWrapper", () => {
});
test("skips audit log creation when no action/targetType provided", async () => {
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
"@/modules/ee/audit-logs/lib/handler"
)) as unknown as { queueAuditEvent: Mock };
const { queueAuditEvent: mockedQueueAuditEvent } =
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
"@/app/middleware/endpoint-validator"
);
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
+1 -5
View File
@@ -1,4 +1,4 @@
import cuid2 from "@paralleldrive/cuid2";
import * as 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,10 +20,6 @@ vi.mock("@paralleldrive/cuid2", () => {
const isCuidMock = vi.fn();
return {
default: {
createId: createIdMock,
isCuid: isCuidMock,
},
createId: createIdMock,
isCuid: isCuidMock,
};
+3 -3
View File
@@ -1,10 +1,10 @@
import cuid2 from "@paralleldrive/cuid2";
import { createId, isCuid } 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 = cuid2.createId();
const cuid = createId();
if (!isEncrypted) {
return cuid;
}
@@ -30,7 +30,7 @@ export const validateSurveySingleUseId = (surveySingleUseId: string): string | u
return undefined;
}
if (cuid2.isCuid(decryptedCuid)) {
if (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);
@@ -14,31 +14,39 @@ const ZCreateOrganizationAction = z.object({
organizationName: z.string(),
});
export const createOrganizationAction = authenticatedActionClient.schema(ZCreateOrganizationAction).action(
withAuditLogging(
"created",
"organization",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const hasNoOrganizations = await gethasNoOrganizations();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
export const createOrganizationAction = authenticatedActionClient
.inputSchema(ZCreateOrganizationAction)
.action(
withAuditLogging(
"created",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const hasNoOrganizations = await gethasNoOrganizations();
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!hasNoOrganizations && !isMultiOrgEnabled) {
throw new OperationNotAllowedError("This action can only be performed on a fresh instance.");
if (!hasNoOrganizations && !isMultiOrgEnabled) {
throw new OperationNotAllowedError("This action can only be performed on a fresh instance.");
}
const newOrganization = await createOrganization({
name: parsedInput.organizationName,
});
await createMembership(newOrganization.id, ctx.user.id, {
role: "owner",
accepted: true,
});
ctx.auditLoggingCtx.organizationId = newOrganization.id;
ctx.auditLoggingCtx.newObject = newOrganization;
return newOrganization;
}
const newOrganization = await createOrganization({
name: parsedInput.organizationName,
});
await createMembership(newOrganization.id, ctx.user.id, {
role: "owner",
accepted: true,
});
ctx.auditLoggingCtx.organizationId = newOrganization.id;
ctx.auditLoggingCtx.newObject = newOrganization;
return newOrganization;
}
)
);
)
);
+18 -1
View File
@@ -150,7 +150,9 @@ checksums:
common/copy_link: 57a37acfe6d7ed71d00fbbc8079fbb35
common/count_attributes: 042fba9baffef5afe2c24f13d4f50697
common/count_contacts: b1c413a4b06961b71b6aeee95d6775d7
common/count_members: 8cabb9805075f20e3977b919b3b2fdc5
common/count_responses: 690118a456c01c5b4d437ae82b50b131
common/count_selections: c0f581d21468af2f46dad171921f71ba
common/create_new_organization: 51dae7b33143686ee218abf5bea764a5
common/create_segment: 9d8291cd4d778b53b73bbc84fd91c181
common/create_survey: 1cfbba08d34876566d84b2960054a987
@@ -180,6 +182,8 @@ checksums:
common/download: 56b7d0834952b39ee394b44bd8179178
common/draft: e8a92958ad300aacfe46c2bf6644927e
common/duplicate: 27756566785c2b8463e21582c4bb619b
common/duplicate_copy: 68d2201918610ca87c2914b61dc8010f
common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
common/edit: eee7f39ff90b18852afc1671f21fbaa9
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
@@ -365,6 +369,7 @@ checksums:
common/show_response_count: 609e5dc7c074d57e711a728fa2f8eb79
common/shown: 63e4ffb245c05e04b636446c3dbdd8df
common/size: 227fadeeff951e041ff42031a11a4626
common/skip: b7f28dfa2f58b80b149bb82b392d0291
common/skipped: d496f0f667e1b4364b954db71335d4ef
common/skips: 99de7579122a3fa6ec5e2a47f3fd8b34
common/some_files_failed_to_upload: a0e26efeb29ae905257ecf93b112dff0
@@ -434,6 +439,7 @@ checksums:
common/website_survey: 17513d25a07b6361768a15ec622b021b
common/weeks: 545de30df4f44d3f6d1d344af6a10815
common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7
common/workflows: b0c9c8615a9ba7d9cb73e767290a7f72
common/workspace_configuration: d0a5812d6a97d7724d565b1017c34387
common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1
common/workspace_creation_description: aea2f480ba0c54c5cabac72c9c900ddf
@@ -1681,7 +1687,6 @@ checksums:
environments/surveys/edit/welcome_message: 986a434e3895c8ee0b267df95cc40051
environments/surveys/edit/without_a_filter_all_of_your_users_can_be_surveyed: 451990569c61f25d01044cc45b1ce122
environments/surveys/edit/you_have_not_created_a_segment_yet: c6658bd1cee9c5c957c675db044708dd
environments/surveys/edit/you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations: 04241177ba989ef4c1d8c01e1a7b8541
environments/surveys/edit/your_description_here_recall_information_with: 60f73a3cc9bdb9afea2166a7db8fd618
environments/surveys/edit/your_question_here_recall_information_with: 6395bd54f5167830c9d662ba403da167
environments/surveys/edit/your_web_app: 07234bed03a33330dc50ae9fcf0174f3
@@ -1927,6 +1932,7 @@ checksums:
environments/surveys/summary/starts: 3153990a4ade414f501a7e63ab771362
environments/surveys/summary/starts_tooltip: 0a7dd01320490dbbea923053fa1ccad6
environments/surveys/summary/survey_reset_successfully: f53db36a28980ef4766215cf13f01e51
environments/surveys/summary/survey_results: b7d86f636beaee2b4d5746bdda058d07
environments/surveys/summary/this_month: 50845a38865204a97773c44dcd2ebb90
environments/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
environments/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
@@ -3103,3 +3109,14 @@ checksums:
templates/usability_question_9_headline: 5850229e97ae97698ce90b330ea49682
templates/usability_rating_description: 8c4f3818fe830ae544611f816265f1a1
templates/usability_score_name: 5cbf1172d24dfcb17d979dff6dfdf7e2
workflows/coming_soon_description: 1e0621d287924d84fb539afab7372b23
workflows/coming_soon_title: d79be80559c70c828cf20811d2ed5039
workflows/follow_up_label: 8cafe669370271035aeac8e8cab0f123
workflows/follow_up_placeholder: 0c26f9e4f82429acb2ac7525a3e8f24e
workflows/generate_button: b194b6172a49af8374a19dd2cf39cfdc
workflows/heading: a98a6b14d3e955f38cc16386df9a4111
workflows/placeholder: 0d24da3af3b860b8f943c83efdeef227
workflows/subheading: ebf5e3b3aeb85e13e843358cc5476f42
workflows/submit_button: 7a062f2de02ce60b1d73e510ff1ca094
workflows/thank_you_description: 842579609c6bf16a1d6c57a333fd5125
workflows/thank_you_title: 07edd8c50685a52c0969d711df26d768
+1 -1
View File
@@ -159,7 +159,7 @@ export const BREVO_LIST_ID = env.BREVO_LIST_ID;
export const UNSPLASH_ACCESS_KEY = env.UNSPLASH_ACCESS_KEY;
export const UNSPLASH_ALLOWED_DOMAINS = ["api.unsplash.com"];
export const STRIPE_API_VERSION = "2024-06-20";
export const STRIPE_API_VERSION = "2026-02-25.clover";
// Maximum number of attribute classes allowed:
export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150;
+2 -2
View File
@@ -71,8 +71,8 @@ export const getDisplaysBySurveyIdWithContact = reactCache(
async (surveyId: string, limit?: number, offset?: number): Promise<TDisplayWithContact[]> => {
validateInputs(
[surveyId, ZId],
[limit, z.number().int().min(1).optional()],
[offset, z.number().int().nonnegative().optional()]
[limit, z.int().min(1).optional()],
[offset, z.int().nonnegative().optional()]
);
try {
+10 -14
View File
@@ -14,7 +14,7 @@ export const env = createEnv({
CRON_SECRET: z.string().optional(),
BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.string().url(),
DATABASE_URL: z.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"]).default("production"),
ENVIRONMENT: z.enum(["production", "staging"]).prefault("production"),
GITHUB_ID: z.string().optional(),
GITHUB_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
@@ -31,21 +31,20 @@ 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.string().url().optional(),
HTTPS_PROXY: z.string().url().optional(),
HTTP_PROXY: z.url().optional(),
HTTPS_PROXY: z.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.string().url().optional(),
CHATWOOT_BASE_URL: z.url().optional(),
IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
LOG_LEVEL: z.enum(["debug", "info", "warn", "error", "fatal"]).optional(),
MAIL_FROM: z.string().email().optional(),
NEXTAUTH_URL: z.string().url().optional(),
MAIL_FROM: z.email().optional(),
NEXTAUTH_URL: z.url().optional(),
NEXTAUTH_SECRET: z.string().optional(),
MAIL_FROM_NAME: z.string().optional(),
NOTION_OAUTH_CLIENT_ID: z.string().optional(),
@@ -58,10 +57,9 @@ export const env = createEnv({
REDIS_URL:
process.env.NODE_ENV === "test"
? z.string().optional()
: z.string().url("REDIS_URL is required for caching, rate limiting, and audit logging"),
: z.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 === "")),
@@ -86,7 +84,6 @@ export const env = createEnv({
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
PUBLIC_URL: z
.string()
.url()
.refine(
(url) => {
@@ -98,12 +95,11 @@ export const env = createEnv({
}
},
{
message: "PUBLIC_URL must be a valid URL with a proper host (e.g., https://example.com)",
error: "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 === "")),
@@ -112,7 +108,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.string().url().optional(),
WEBAPP_URL: z.url().optional(),
UNSPLASH_ACCESS_KEY: z.string().optional(),
NODE_ENV: z.enum(["development", "production", "test"]).optional(),
+18
View File
@@ -130,84 +130,102 @@ export const appLanguages = [
code: "de-DE",
label: {
"en-US": "German",
native: "Deutsch",
},
},
{
code: "en-US",
label: {
"en-US": "English (US)",
native: "English (US)",
},
},
{
code: "es-ES",
label: {
"en-US": "Spanish",
native: "Español",
},
},
{
code: "fr-FR",
label: {
"en-US": "French",
native: "Français",
},
},
{
code: "hu-HU",
label: {
"en-US": "Hungarian",
native: "Magyar",
},
},
{
code: "ja-JP",
label: {
"en-US": "Japanese",
native: "日本語",
},
},
{
code: "nl-NL",
label: {
"en-US": "Dutch",
native: "Nederlands",
},
},
{
code: "pt-BR",
label: {
"en-US": "Portuguese (Brazil)",
native: "Português (Brasil)",
},
},
{
code: "pt-PT",
label: {
"en-US": "Portuguese (Portugal)",
native: "Português (Portugal)",
},
},
{
code: "ro-RO",
label: {
"en-US": "Romanian",
native: "Română",
},
},
{
code: "ru-RU",
label: {
"en-US": "Russian",
native: "Русский",
},
},
{
code: "sv-SE",
label: {
"en-US": "Swedish",
native: "Svenska",
},
},
{
code: "zh-Hans-CN",
label: {
"en-US": "Chinese (Simplified)",
native: "简体中文",
},
},
{
code: "zh-Hant-TW",
label: {
"en-US": "Chinese (Traditional)",
native: "繁體中文",
},
},
];
export const sortedAppLanguages = [...appLanguages].sort((a, b) =>
a.label["en-US"].localeCompare(b.label["en-US"])
);
+1 -1
View File
@@ -267,7 +267,7 @@ export const getResponses = reactCache(
[limit, ZOptionalNumber],
[offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()],
[cursor, z.string().cuid2().optional()]
[cursor, z.cuid2().optional()]
);
limit = limit ?? RESPONSES_PER_PAGE;
+39 -44
View File
@@ -11,6 +11,7 @@ import {
getOrganizationByEnvironmentId,
subscribeOrganizationMembersToSurveyResponses,
} from "@/lib/organization/service";
import { TriggerUpdate } from "@/modules/survey/editor/types/survey-trigger";
import { getActionClasses } from "../actionClass/service";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
@@ -22,15 +23,6 @@ import {
validateMediaAndPrepareBlocks,
} from "./utils";
interface TriggerUpdate {
create?: Array<{ actionClassId: string }>;
deleteMany?: {
actionClassId: {
in: string[];
};
};
}
export const selectSurvey = {
id: true,
createdAt: true,
@@ -114,19 +106,32 @@ export const selectSurvey = {
slug: true,
} satisfies Prisma.SurveySelect;
const getTriggerIds = (triggers: TSurvey["triggers"]): string[] | null => {
if (!triggers) return null;
if (!Array.isArray(triggers)) {
throw new InvalidInputError("Invalid trigger id");
}
return triggers.map((trigger) => {
const actionClassId = trigger?.actionClass?.id;
if (typeof actionClassId !== "string") {
throw new InvalidInputError("Invalid trigger id");
}
return actionClassId;
});
};
export const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: ActionClass[]) => {
if (!triggers) return;
const triggerIds = getTriggerIds(triggers);
if (!triggerIds) return;
// check if all the triggers are valid
triggers.forEach((trigger) => {
if (!actionClasses.find((actionClass) => actionClass.id === trigger.actionClass.id)) {
triggerIds.forEach((triggerId) => {
if (!actionClasses.find((actionClass) => actionClass.id === triggerId)) {
throw new InvalidInputError("Invalid trigger id");
}
});
// check if all the triggers are unique
const triggerIds = triggers.map((trigger) => trigger.actionClass.id);
if (new Set(triggerIds).size !== triggerIds.length) {
throw new InvalidInputError("Duplicate trigger id");
}
@@ -137,36 +142,33 @@ export const handleTriggerUpdates = (
currentTriggers: TSurvey["triggers"],
actionClasses: ActionClass[]
) => {
if (!updatedTriggers) return {};
const updatedTriggerIds = getTriggerIds(updatedTriggers);
if (!updatedTriggerIds) return {};
checkTriggersValidity(updatedTriggers, actionClasses);
const currentTriggerIds = currentTriggers.map((trigger) => trigger.actionClass.id);
const updatedTriggerIds = updatedTriggers.map((trigger) => trigger.actionClass.id);
const currentTriggerIds = getTriggerIds(currentTriggers) ?? [];
// added triggers are triggers that are not in the current triggers and are there in the new triggers
const addedTriggers = updatedTriggers.filter(
(trigger) => !currentTriggerIds.includes(trigger.actionClass.id)
);
const addedTriggerIds = updatedTriggerIds.filter((triggerId) => !currentTriggerIds.includes(triggerId));
// deleted triggers are triggers that are not in the new triggers and are there in the current triggers
const deletedTriggers = currentTriggers.filter(
(trigger) => !updatedTriggerIds.includes(trigger.actionClass.id)
);
const deletedTriggerIds = currentTriggerIds.filter((triggerId) => !updatedTriggerIds.includes(triggerId));
// Construct the triggers update object
const triggersUpdate: TriggerUpdate = {};
if (addedTriggers.length > 0) {
triggersUpdate.create = addedTriggers.map((trigger) => ({
actionClassId: trigger.actionClass.id,
if (addedTriggerIds.length > 0) {
triggersUpdate.create = addedTriggerIds.map((triggerId) => ({
actionClassId: triggerId,
}));
}
if (deletedTriggers.length > 0) {
if (deletedTriggerIds.length > 0) {
// disconnect the public triggers from the survey
triggersUpdate.deleteMany = {
actionClassId: {
in: deletedTriggers.map((trigger) => trigger.actionClass.id),
in: deletedTriggerIds,
},
};
}
@@ -600,21 +602,16 @@ export const createSurvey = async (
);
try {
const { createdBy, ...restSurveyBody } = parsedSurveyBody;
// empty languages array
if (!restSurveyBody.languages?.length) {
delete restSurveyBody.languages;
}
const { createdBy, languages, ...restSurveyBody } = parsedSurveyBody;
const actionClasses = await getActionClasses(parsedEnvironmentId);
// @ts-expect-error
let data: Omit<Prisma.SurveyCreateInput, "environment"> = {
...restSurveyBody,
// TODO: Create with attributeFilters
// @ts-expect-error - languages would be undefined in case of empty array
languages: languages?.length ? languages : undefined,
triggers: restSurveyBody.triggers
? handleTriggerUpdates(restSurveyBody.triggers, [], actionClasses)
? // @ts-expect-error - triggers' createdAt and updatedAt are actually dates
handleTriggerUpdates(restSurveyBody.triggers, [], actionClasses)
: undefined,
attributeFilters: undefined,
};
@@ -783,15 +780,13 @@ export const loadNewSegmentInSurvey = async (surveyId: string, newSegmentId: str
};
}
// TODO: Fix this, this happens because the survey type "web" is no longer in the zod types but its required in the schema for migration
// @ts-expect-error
const modifiedSurvey: TSurvey = {
...prismaSurvey, // Properties from prismaSurvey
const modifiedSurvey = {
...prismaSurvey,
segment: surveySegment,
customHeadScriptsMode: prismaSurvey.customHeadScriptsMode,
};
return modifiedSurvey;
return modifiedSurvey as TSurvey;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
+1 -1
View File
@@ -52,7 +52,7 @@ export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
});
export const getUserByEmail = reactCache(async (email: string): Promise<TUser | null> => {
validateInputs([email, z.string().email()]);
validateInputs([email, z.email()]);
try {
const user = await prisma.user.findFirst({
@@ -1,4 +1,4 @@
import cuid2 from "@paralleldrive/cuid2";
import * as cuid2 from "@paralleldrive/cuid2";
import { beforeEach, describe, expect, test, vi } from "vitest";
import * as crypto from "@/lib/crypto";
import { env } from "@/lib/env";
+2 -2
View File
@@ -1,10 +1,10 @@
import cuid2 from "@paralleldrive/cuid2";
import { createId } 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 = cuid2.createId();
const cuid = createId();
if (!isEncrypted) {
return cuid;
}
@@ -0,0 +1,297 @@
import dns from "node:dns";
import { afterEach, describe, expect, test, vi } from "vitest";
import { validateWebhookUrl } from "./validate-webhook-url";
vi.mock("node:dns", () => ({
default: {
resolve: vi.fn(),
resolve6: vi.fn(),
},
}));
const mockResolve = vi.mocked(dns.resolve);
const mockResolve6 = vi.mocked(dns.resolve6);
type DnsCallback = (err: NodeJS.ErrnoException | null, addresses: string[]) => void;
const setupDnsResolution = (ipv4: string[] | null, ipv6: string[] | null = null): void => {
// dns.resolve/resolve6 have overloaded signatures; we only mock the (hostname, callback) form
mockResolve.mockImplementation(((_hostname: string, callback: DnsCallback) => {
if (ipv4) {
callback(null, ipv4);
} else {
callback(new Error("ENOTFOUND"), []);
}
}) as never);
mockResolve6.mockImplementation(((_hostname: string, callback: DnsCallback) => {
if (ipv6) {
callback(null, ipv6);
} else {
callback(new Error("ENOTFOUND"), []);
}
}) as never);
};
afterEach(() => {
vi.clearAllMocks();
});
describe("validateWebhookUrl", () => {
describe("valid public URLs", () => {
test("accepts HTTPS URL resolving to a public IPv4 address", async () => {
setupDnsResolution(["93.184.216.34"]);
await expect(validateWebhookUrl("https://example.com/webhook")).resolves.toBeUndefined();
});
test("accepts HTTP URL resolving to a public IPv4 address", async () => {
setupDnsResolution(["93.184.216.34"]);
await expect(validateWebhookUrl("http://example.com/webhook")).resolves.toBeUndefined();
});
test("accepts URL with port and path segments", async () => {
setupDnsResolution(["93.184.216.34"]);
await expect(validateWebhookUrl("https://example.com:8443/api/v1/webhook")).resolves.toBeUndefined();
});
test("accepts URL resolving to a public IPv6 address", async () => {
setupDnsResolution(null, ["2606:2800:220:1:248:1893:25c8:1946"]);
await expect(validateWebhookUrl("https://example.com/webhook")).resolves.toBeUndefined();
});
test("accepts a public IPv4 address as hostname", async () => {
await expect(validateWebhookUrl("https://93.184.216.34/webhook")).resolves.toBeUndefined();
});
});
describe("URL format validation", () => {
test("rejects a completely malformed string", async () => {
await expect(validateWebhookUrl("not-a-url")).rejects.toThrow("Invalid webhook URL format");
});
test("rejects an empty string", async () => {
await expect(validateWebhookUrl("")).rejects.toThrow("Invalid webhook URL format");
});
});
describe("protocol validation", () => {
test("rejects FTP protocol", async () => {
await expect(validateWebhookUrl("ftp://example.com/file")).rejects.toThrow(
"Webhook URL must use HTTPS or HTTP protocol"
);
});
test("rejects file:// protocol", async () => {
await expect(validateWebhookUrl("file:///etc/passwd")).rejects.toThrow(
"Webhook URL must use HTTPS or HTTP protocol"
);
});
test("rejects javascript: protocol", async () => {
await expect(validateWebhookUrl("javascript:alert(1)")).rejects.toThrow(
"Webhook URL must use HTTPS or HTTP protocol"
);
});
});
describe("blocked hostname validation", () => {
test("rejects localhost", async () => {
await expect(validateWebhookUrl("http://localhost/admin")).rejects.toThrow(
"Webhook URL must not point to localhost or internal services"
);
});
test("rejects localhost.localdomain", async () => {
await expect(validateWebhookUrl("https://localhost.localdomain/path")).rejects.toThrow(
"Webhook URL must not point to localhost or internal services"
);
});
test("rejects metadata.google.internal", async () => {
await expect(validateWebhookUrl("http://metadata.google.internal/computeMetadata/v1/")).rejects.toThrow(
"Webhook URL must not point to localhost or internal services"
);
});
});
describe("private IPv4 literal blocking", () => {
test("rejects 127.0.0.1 (loopback)", async () => {
await expect(validateWebhookUrl("http://127.0.0.1/metadata")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 127.0.0.53 (loopback range)", async () => {
await expect(validateWebhookUrl("http://127.0.0.53/")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 10.0.0.1 (Class A private)", async () => {
await expect(validateWebhookUrl("http://10.0.0.1/internal")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 172.16.0.1 (Class B private)", async () => {
await expect(validateWebhookUrl("http://172.16.0.1/internal")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 172.31.255.255 (Class B private upper bound)", async () => {
await expect(validateWebhookUrl("http://172.31.255.255/internal")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 192.168.1.1 (Class C private)", async () => {
await expect(validateWebhookUrl("http://192.168.1.1/internal")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 169.254.169.254 (AWS/GCP/Azure metadata endpoint)", async () => {
await expect(validateWebhookUrl("http://169.254.169.254/latest/meta-data/")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 0.0.0.0 ('this' network)", async () => {
await expect(validateWebhookUrl("http://0.0.0.0/")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 100.64.0.1 (CGNAT / shared address space)", async () => {
await expect(validateWebhookUrl("http://100.64.0.1/")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
});
describe("DNS resolution with private IP results", () => {
test("rejects hostname resolving to loopback address", async () => {
setupDnsResolution(["127.0.0.1"]);
await expect(validateWebhookUrl("https://evil.com/steal")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to cloud metadata endpoint IP", async () => {
setupDnsResolution(["169.254.169.254"]);
await expect(validateWebhookUrl("https://attacker.com/ssrf")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to Class A private network", async () => {
setupDnsResolution(["10.0.0.5"]);
await expect(validateWebhookUrl("https://internal.service/api")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to Class C private network", async () => {
setupDnsResolution(["192.168.0.1"]);
await expect(validateWebhookUrl("https://sneaky.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to IPv6 loopback", async () => {
setupDnsResolution(null, ["::1"]);
await expect(validateWebhookUrl("https://sneaky.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to IPv6 link-local", async () => {
setupDnsResolution(null, ["fe80::1"]);
await expect(validateWebhookUrl("https://link-local.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to IPv6 unique local address", async () => {
setupDnsResolution(null, ["fd12:3456:789a::1"]);
await expect(validateWebhookUrl("https://ula.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to IPv4-mapped IPv6 private address (dotted)", async () => {
setupDnsResolution(null, ["::ffff:192.168.1.1"]);
await expect(validateWebhookUrl("https://mapped.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to IPv4-mapped IPv6 private address (hex-encoded)", async () => {
setupDnsResolution(null, ["::ffff:c0a8:0101"]); // 192.168.1.1 in hex
await expect(validateWebhookUrl("https://hex-mapped.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hex-encoded IPv4-mapped loopback (::ffff:7f00:0001)", async () => {
setupDnsResolution(null, ["::ffff:7f00:0001"]); // 127.0.0.1 in hex
await expect(validateWebhookUrl("https://hex-loopback.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hex-encoded IPv4-mapped metadata endpoint (::ffff:a9fe:a9fe)", async () => {
setupDnsResolution(null, ["::ffff:a9fe:a9fe"]); // 169.254.169.254 in hex
await expect(validateWebhookUrl("https://hex-metadata.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("accepts hex-encoded IPv4-mapped public address", async () => {
setupDnsResolution(null, ["::ffff:5db8:d822"]); // 93.184.216.34 in hex
await expect(validateWebhookUrl("https://hex-public.example.com/webhook")).resolves.toBeUndefined();
});
test("rejects when any resolved IP is private (mixed public + private)", async () => {
setupDnsResolution(["93.184.216.34", "192.168.1.1"]);
await expect(validateWebhookUrl("https://dual.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects unresolvable hostname", async () => {
setupDnsResolution(null, null);
await expect(validateWebhookUrl("https://nonexistent.invalid/path")).rejects.toThrow(
"Could not resolve webhook URL hostname"
);
});
test("rejects with timeout error when DNS resolution hangs", async () => {
vi.useFakeTimers();
mockResolve.mockImplementation((() => {
// never calls callback — simulates a hanging DNS server
}) as never);
const promise = validateWebhookUrl("https://slow-dns.example.com/webhook");
const assertion = expect(promise).rejects.toThrow(
"DNS resolution timed out for webhook URL hostname: slow-dns.example.com"
);
await vi.advanceTimersByTimeAsync(3000);
await assertion;
vi.useRealTimers();
});
});
describe("error type", () => {
test("throws InvalidInputError (not generic Error)", async () => {
await expect(validateWebhookUrl("http://127.0.0.1/")).rejects.toMatchObject({
name: "InvalidInputError",
});
});
});
});
+176
View File
@@ -0,0 +1,176 @@
import "server-only";
import dns from "node:dns";
import { InvalidInputError } from "@formbricks/types/errors";
const BLOCKED_HOSTNAMES = new Set([
"localhost",
"localhost.localdomain",
"ip6-localhost",
"ip6-loopback",
"metadata.google.internal",
]);
const PRIVATE_IPV4_PATTERNS: RegExp[] = [
/^127\./, // 127.0.0.0/8 Loopback
/^10\./, // 10.0.0.0/8 Class A private
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 Class B private
/^192\.168\./, // 192.168.0.0/16 Class C private
/^169\.254\./, // 169.254.0.0/16 Link-local (AWS/GCP/Azure metadata)
/^0\./, // 0.0.0.0/8 "This" network
/^100\.(6[4-9]|[7-9]\d|1[0-2]\d)\./, // 100.64.0.0/10 Shared address space (RFC 6598)
/^192\.0\.0\./, // 192.0.0.0/24 IETF protocol assignments
/^192\.0\.2\./, // 192.0.2.0/24 TEST-NET-1 (documentation)
/^198\.51\.100\./, // 198.51.100.0/24 TEST-NET-2 (documentation)
/^203\.0\.113\./, // 203.0.113.0/24 TEST-NET-3 (documentation)
/^198\.1[89]\./, // 198.18.0.0/15 Benchmarking
/^224\./, // 224.0.0.0/4 Multicast
/^240\./, // 240.0.0.0/4 Reserved for future use
/^255\.255\.255\.255$/, // Limited broadcast
];
const PRIVATE_IPV6_PREFIXES = [
"::1", // Loopback
"fe80:", // Link-local
"fc", // Unique local address (ULA, fc00::/7 — covers fc00:: through fdff::)
"fd", // Unique local address (ULA, fc00::/7 — covers fc00:: through fdff::)
];
const isPrivateIPv4 = (ip: string): boolean => {
return PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(ip));
};
const hexMappedToIPv4 = (hexPart: string): string | null => {
const groups = hexPart.split(":");
if (groups.length !== 2) return null;
const high = Number.parseInt(groups[0], 16);
const low = Number.parseInt(groups[1], 16);
if (Number.isNaN(high) || Number.isNaN(low) || high > 0xffff || low > 0xffff) return null;
return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
};
const isIPv4Mapped = (normalized: string): boolean => {
if (!normalized.startsWith("::ffff:")) return false;
const suffix = normalized.slice(7); // strip "::ffff:"
if (suffix.includes(".")) {
return isPrivateIPv4(suffix);
}
const dotted = hexMappedToIPv4(suffix);
return dotted !== null && isPrivateIPv4(dotted);
};
const isPrivateIPv6 = (ip: string): boolean => {
const normalized = ip.toLowerCase();
if (normalized === "::") return true;
if (isIPv4Mapped(normalized)) return true;
return PRIVATE_IPV6_PREFIXES.some((prefix) => normalized.startsWith(prefix));
};
const isPrivateIP = (ip: string): boolean => {
return isPrivateIPv4(ip) || isPrivateIPv6(ip);
};
const DNS_TIMEOUT_MS = 3000;
const resolveHostnameToIPs = (hostname: string): Promise<string[]> => {
return new Promise((resolve, reject) => {
let settled = false;
const settle = <T>(fn: (value: T) => void, value: T): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn(value);
};
const timer = setTimeout(() => {
settle(reject, new Error(`DNS resolution timed out for hostname: ${hostname}`));
}, DNS_TIMEOUT_MS);
dns.resolve(hostname, (errV4, ipv4Addresses) => {
const ipv4 = errV4 ? [] : ipv4Addresses;
dns.resolve6(hostname, (errV6, ipv6Addresses) => {
const ipv6 = errV6 ? [] : ipv6Addresses;
const allAddresses = [...ipv4, ...ipv6];
if (allAddresses.length === 0) {
settle(reject, new Error(`DNS resolution failed for hostname: ${hostname}`));
} else {
settle(resolve, allAddresses);
}
});
});
});
};
const stripIPv6Brackets = (hostname: string): string => {
if (hostname.startsWith("[") && hostname.endsWith("]")) {
return hostname.slice(1, -1);
}
return hostname;
};
const IPV4_LITERAL = /^\d{1,3}(?:\.\d{1,3}){3}$/;
/**
* Validates a webhook URL to prevent Server-Side Request Forgery (SSRF).
*
* Checks performed:
* 1. URL must be well-formed
* 2. Protocol must be HTTPS or HTTP
* 3. Hostname must not be a known internal name (localhost, metadata endpoints)
* 4. IP literal hostnames are checked directly against private ranges
* 5. Domain hostnames are resolved via DNS; all resulting IPs must be public
*
* @throws {InvalidInputError} when the URL fails any validation check
*/
export const validateWebhookUrl = async (url: string): Promise<void> => {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new InvalidInputError("Invalid webhook URL format");
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
throw new InvalidInputError("Webhook URL must use HTTPS or HTTP protocol");
}
const hostname = parsed.hostname;
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
}
// Direct IP literal — validate without DNS resolution
const isIPv4Literal = IPV4_LITERAL.test(hostname);
const isIPv6Literal = hostname.startsWith("[");
if (isIPv4Literal || isIPv6Literal) {
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
return;
}
// Domain name — resolve DNS and validate every resolved IP
let resolvedIPs: string[];
try {
resolvedIPs = await resolveHostnameToIPs(hostname);
} catch (error) {
const isTimeout = error instanceof Error && error.message.includes("timed out");
throw new InvalidInputError(
isTimeout
? `DNS resolution timed out for webhook URL hostname: ${hostname}`
: `Could not resolve webhook URL hostname: ${hostname}`
);
}
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
}
};
+23 -7
View File
@@ -175,9 +175,11 @@
"copy": "Kopieren",
"copy_code": "Code kopieren",
"copy_link": "Link kopieren",
"count_attributes": "{value, plural, one {{value} Attribut} other {{value} Attribute}}",
"count_contacts": "{value, plural, one {{value} Kontakt} other {{value} Kontakte}}",
"count_responses": "{value, plural, one {{value} Antwort} other {{value} Antworten}}",
"count_attributes": "{count, plural, one {{count} Attribut} other {{count} Attribute}}",
"count_contacts": "{count, plural, one {{count} Kontakt} other {{count} Kontakte}}",
"count_members": "{count, plural, one {{count} Mitglied} other {{count} Mitglieder}}",
"count_responses": "{count, plural, one {{count} Antwort} other {{count} Antworten}}",
"count_selections": "{count, plural, one {{count} Auswahl} other {{count} Auswahlen}}",
"create_new_organization": "Neue Organisation erstellen",
"create_segment": "Segment erstellen",
"create_survey": "Umfrage erstellen",
@@ -207,6 +209,8 @@
"download": "Herunterladen",
"draft": "Entwurf",
"duplicate": "Duplikat",
"duplicate_copy": "(Kopie)",
"duplicate_copy_number": "(Kopie {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Bearbeiten",
"email": "E-Mail",
@@ -274,7 +278,6 @@
"look_and_feel": "Darstellung",
"manage": "Verwalten",
"marketing": "Marketing",
"member": "Mitglied",
"members": "Mitglieder",
"members_and_teams": "Mitglieder & Teams",
"membership_not_found": "Mitgliedschaft nicht gefunden",
@@ -382,8 +385,6 @@
"select_teams": "Teams auswählen",
"selected": "Ausgewählt",
"selected_questions": "Ausgewählte Fragen",
"selection": "Auswahl",
"selections": "Auswahlen",
"send_test_email": "Test-E-Mail senden",
"session_not_found": "Sitzung nicht gefunden",
"settings": "Einstellungen",
@@ -392,6 +393,7 @@
"show_response_count": "Antwortanzahl anzeigen",
"shown": "Angezeigt",
"size": "Größe",
"skip": "Überspringen",
"skipped": "Übersprungen",
"skips": "Übersprungen",
"some_files_failed_to_upload": "Einige Dateien konnten nicht hochgeladen werden",
@@ -461,6 +463,7 @@
"website_survey": "Website-Umfrage",
"weeks": "Wochen",
"welcome_card": "Willkommenskarte",
"workflows": "Workflows",
"workspace_configuration": "Projektkonfiguration",
"workspace_created_successfully": "Projekt erfolgreich erstellt",
"workspace_creation_description": "Organisieren Sie Umfragen in Projekten für eine bessere Zugriffskontrolle.",
@@ -1756,7 +1759,6 @@
"welcome_message": "Willkommensnachricht",
"without_a_filter_all_of_your_users_can_be_surveyed": "Ohne Filter können alle deine Nutzer befragt werden.",
"you_have_not_created_a_segment_yet": "Du hast noch keinen Segment erstellt.",
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "Sie müssen zwei oder mehr Sprachen in Ihrem Workspace eingerichtet haben, um mit Übersetzungen zu arbeiten.",
"your_description_here_recall_information_with": "Deine Beschreibung hier. Informationen abrufen mit @",
"your_question_here_recall_information_with": "Deine Frage hier. Informationen abrufen mit @",
"your_web_app": "Deine Web-App",
@@ -2030,6 +2032,7 @@
"starts": "Startet",
"starts_tooltip": "So oft wurde die Umfrage gestartet.",
"survey_reset_successfully": "Umfrage erfolgreich zurückgesetzt! {responseCount} Antworten und {displayCount} Anzeigen wurden gelöscht.",
"survey_results": "{surveyName}-Ergebnisse",
"this_month": "Dieser Monat",
"this_quarter": "Dieses Quartal",
"this_year": "Dieses Jahr",
@@ -3258,5 +3261,18 @@
"usability_question_9_headline": "Ich fühlte mich beim Benutzen des Systems sicher.",
"usability_rating_description": "Bewerte die wahrgenommene Benutzerfreundlichkeit, indem du die Nutzer bittest, ihre Erfahrung mit deinem Produkt mittels eines standardisierten 10-Fragen-Fragebogens zu bewerten.",
"usability_score_name": "System Usability Score Survey (SUS)"
},
"workflows": {
"coming_soon_description": "Danke, dass du deine Workflow-Idee mit uns geteilt hast! Wir arbeiten gerade an diesem Feature und dein Feedback hilft uns dabei, genau das zu entwickeln, was du brauchst.",
"coming_soon_title": "Wir sind fast da!",
"follow_up_label": "Gibt es noch etwas, das du hinzufügen möchtest?",
"follow_up_placeholder": "Welche spezifischen Aufgaben möchtest du automatisieren? Gibt es Tools oder Integrationen, die du gerne einbinden würdest?",
"generate_button": "Workflow generieren",
"heading": "Welchen Workflow möchtest du erstellen?",
"placeholder": "Beschreibe den Workflow, den du generieren möchtest...",
"subheading": "Generiere deinen Workflow in Sekunden.",
"submit_button": "Details hinzufügen",
"thank_you_description": "Dein Input hilft uns dabei, das Workflows-Feature zu entwickeln, das du wirklich brauchst. Wir halten dich über unsere Fortschritte auf dem Laufenden.",
"thank_you_title": "Danke für dein Feedback!"
}
}
+23 -7
View File
@@ -175,9 +175,11 @@
"copy": "Copy",
"copy_code": "Copy code",
"copy_link": "Copy Link",
"count_attributes": "{value, plural, one {{value} attribute} other {{value} attributes}}",
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacts}}",
"count_responses": "{value, plural, one {{value} response} other {{value} responses}}",
"count_attributes": "{count, plural, one {{count} attribute} other {{count} attributes}}",
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacts}}",
"count_members": "{count, plural, one {{count} member} other {{count} members}}",
"count_responses": "{count, plural, one {{count} response} other {{count} responses}}",
"count_selections": "{count, plural, one {{count} selection} other {{count} selections}}",
"create_new_organization": "Create new organization",
"create_segment": "Create segment",
"create_survey": "Create survey",
@@ -207,6 +209,8 @@
"download": "Download",
"draft": "Draft",
"duplicate": "Duplicate",
"duplicate_copy": "(copy)",
"duplicate_copy_number": "(copy {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Edit",
"email": "Email",
@@ -274,7 +278,6 @@
"look_and_feel": "Look & Feel",
"manage": "Manage",
"marketing": "Marketing",
"member": "Member",
"members": "Members",
"members_and_teams": "Members & Teams",
"membership_not_found": "Membership not found",
@@ -382,8 +385,6 @@
"select_teams": "Select teams",
"selected": "Selected",
"selected_questions": "Selected questions",
"selection": "Selection",
"selections": "Selections",
"send_test_email": "Send test email",
"session_not_found": "Session not found",
"settings": "Settings",
@@ -392,6 +393,7 @@
"show_response_count": "Show response count",
"shown": "Shown",
"size": "Size",
"skip": "Skip",
"skipped": "Skipped",
"skips": "Skips",
"some_files_failed_to_upload": "Some files failed to upload",
@@ -461,6 +463,7 @@
"website_survey": "Website Survey",
"weeks": "weeks",
"welcome_card": "Welcome card",
"workflows": "Workflows",
"workspace_configuration": "Workspace Configuration",
"workspace_created_successfully": "Workspace created successfully",
"workspace_creation_description": "Organize surveys in workspaces for better access control.",
@@ -1756,7 +1759,6 @@
"welcome_message": "Welcome message",
"without_a_filter_all_of_your_users_can_be_surveyed": "Without a filter, all of your users can be surveyed.",
"you_have_not_created_a_segment_yet": "You have not created a segment yet",
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "You need to have two or more languages set up in your workspace to work with translations.",
"your_description_here_recall_information_with": "Your description here. Recall information with @",
"your_question_here_recall_information_with": "Your question here. Recall information with @",
"your_web_app": "Your web app",
@@ -2030,6 +2032,7 @@
"starts": "Starts",
"starts_tooltip": "Number of times the survey has been started.",
"survey_reset_successfully": "Survey reset successfully. {responseCount} responses and {displayCount} displays were deleted.",
"survey_results": "{surveyName} Results",
"this_month": "This month",
"this_quarter": "This quarter",
"this_year": "This year",
@@ -3258,5 +3261,18 @@
"usability_question_9_headline": "I felt confident while using the system.",
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
"usability_score_name": "System Usability Score (SUS)"
},
"workflows": {
"coming_soon_description": "Thank you for sharing your workflow idea with us! We are currently designing this feature and your feedback will help us build exactly what you need.",
"coming_soon_title": "We are almost there!",
"follow_up_label": "Is there anything else you'd like to add?",
"follow_up_placeholder": "What specific tasks would you like to automate? Any tools or integrations you'd want included?",
"generate_button": "Generate workflow",
"heading": "What workflow do you want to create?",
"placeholder": "Describe the workflow you want to generate...",
"subheading": "Generate your workflow in seconds.",
"submit_button": "Add details",
"thank_you_description": "Your input helps us build the Workflows feature you actually need. We'll keep you posted on our progress.",
"thank_you_title": "Thank you for your feedback!"
}
}
+23 -7
View File
@@ -175,9 +175,11 @@
"copy": "Copiar",
"copy_code": "Copiar código",
"copy_link": "Copiar enlace",
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
"count_contacts": "{value, plural, one {{value} contacto} other {{value} contactos}}",
"count_responses": "{value, plural, one {{value} respuesta} other {{value} respuestas}}",
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
"count_contacts": "{count, plural, one {{count} contacto} other {{count} contactos}}",
"count_members": "{count, plural, one {{count} miembro} other {{count} miembros}}",
"count_responses": "{count, plural, one {{count} respuesta} other {{count} respuestas}}",
"count_selections": "{count, plural, one {{count} selección} other {{count} selecciones}}",
"create_new_organization": "Crear organización nueva",
"create_segment": "Crear segmento",
"create_survey": "Crear encuesta",
@@ -207,6 +209,8 @@
"download": "Descargar",
"draft": "Borrador",
"duplicate": "Duplicar",
"duplicate_copy": "(copia)",
"duplicate_copy_number": "(copia {copyNumber})",
"e_commerce": "Comercio electrónico",
"edit": "Editar",
"email": "Email",
@@ -274,7 +278,6 @@
"look_and_feel": "Apariencia",
"manage": "Gestionar",
"marketing": "Marketing",
"member": "Miembro",
"members": "Miembros",
"members_and_teams": "Miembros y equipos",
"membership_not_found": "Membresía no encontrada",
@@ -382,8 +385,6 @@
"select_teams": "Seleccionar equipos",
"selected": "Seleccionado",
"selected_questions": "Preguntas seleccionadas",
"selection": "Selección",
"selections": "Selecciones",
"send_test_email": "Enviar correo electrónico de prueba",
"session_not_found": "Sesión no encontrada",
"settings": "Ajustes",
@@ -392,6 +393,7 @@
"show_response_count": "Mostrar recuento de respuestas",
"shown": "Mostrado",
"size": "Tamaño",
"skip": "Omitir",
"skipped": "Omitido",
"skips": "Omisiones",
"some_files_failed_to_upload": "Algunos archivos no se han podido subir",
@@ -461,6 +463,7 @@
"website_survey": "Encuesta de sitio web",
"weeks": "semanas",
"welcome_card": "Tarjeta de bienvenida",
"workflows": "Flujos de trabajo",
"workspace_configuration": "Configuración del proyecto",
"workspace_created_successfully": "Proyecto creado correctamente",
"workspace_creation_description": "Organiza las encuestas en proyectos para un mejor control de acceso.",
@@ -1756,7 +1759,6 @@
"welcome_message": "Mensaje de bienvenida",
"without_a_filter_all_of_your_users_can_be_surveyed": "Sin un filtro, todos tus usuarios pueden ser encuestados.",
"you_have_not_created_a_segment_yet": "Aún no has creado un segmento",
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "Necesitas tener dos o más idiomas configurados en tu proyecto para trabajar con traducciones.",
"your_description_here_recall_information_with": "Tu descripción aquí. Recupera información con @",
"your_question_here_recall_information_with": "Tu pregunta aquí. Recupera información con @",
"your_web_app": "Tu aplicación web",
@@ -2030,6 +2032,7 @@
"starts": "Inicios",
"starts_tooltip": "Número de veces que se ha iniciado la encuesta.",
"survey_reset_successfully": "¡Encuesta restablecida correctamente! Se eliminaron {responseCount} respuestas y {displayCount} visualizaciones.",
"survey_results": "Resultados de {surveyName}",
"this_month": "Este mes",
"this_quarter": "Este trimestre",
"this_year": "Este año",
@@ -3258,5 +3261,18 @@
"usability_question_9_headline": "Me sentí seguro mientras usaba el sistema.",
"usability_rating_description": "Mide la usabilidad percibida pidiendo a los usuarios que valoren su experiencia con tu producto mediante una encuesta estandarizada de 10 preguntas.",
"usability_score_name": "Puntuación de usabilidad del sistema (SUS)"
},
"workflows": {
"coming_soon_description": "¡Gracias por compartir tu idea de flujo de trabajo con nosotros! Actualmente estamos diseñando esta funcionalidad y tus comentarios nos ayudarán a construir exactamente lo que necesitas.",
"coming_soon_title": "¡Ya casi estamos!",
"follow_up_label": "¿Hay algo más que te gustaría añadir?",
"follow_up_placeholder": "¿Qué tareas específicas te gustaría automatizar? ¿Alguna herramienta o integración que quisieras incluir?",
"generate_button": "Generar flujo de trabajo",
"heading": "¿Qué flujo de trabajo quieres crear?",
"placeholder": "Describe el flujo de trabajo que quieres generar...",
"subheading": "Genera tu flujo de trabajo en segundos.",
"submit_button": "Añadir detalles",
"thank_you_description": "Tu aportación nos ayuda a construir la funcionalidad de flujos de trabajo que realmente necesitas. Te mantendremos informado sobre nuestro progreso.",
"thank_you_title": "¡Gracias por tus comentarios!"
}
}
+23 -7
View File
@@ -175,9 +175,11 @@
"copy": "Copier",
"copy_code": "Copier le code",
"copy_link": "Copier le lien",
"count_attributes": "{value, plural, one {{value} attribut} other {{value} attributs}}",
"count_contacts": "{value, plural, one {# contact} other {# contacts} }",
"count_responses": "{value, plural, other {# réponses}}",
"count_attributes": "{count, plural, one {{count} attribut} other {{count} attributs}}",
"count_contacts": "{count, plural, one {# contact} other {# contacts} }",
"count_members": "{count, plural, one {{count} membre} other {{count} membres}}",
"count_responses": "{count, plural, other {# réponses}}",
"count_selections": "{count, plural, one {{count} sélection} other {{count} sélections}}",
"create_new_organization": "Créer une nouvelle organisation",
"create_segment": "Créer un segment",
"create_survey": "Créer un sondage",
@@ -207,6 +209,8 @@
"download": "Télécharger",
"draft": "Brouillon",
"duplicate": "Dupliquer",
"duplicate_copy": "(copie)",
"duplicate_copy_number": "(copie {copyNumber})",
"e_commerce": "E-commerce",
"edit": "Modifier",
"email": "Email",
@@ -274,7 +278,6 @@
"look_and_feel": "Apparence",
"manage": "Gérer",
"marketing": "Marketing",
"member": "Membre",
"members": "Membres",
"members_and_teams": "Membres & Équipes",
"membership_not_found": "Abonnement non trouvé",
@@ -382,8 +385,6 @@
"select_teams": "Sélectionner les équipes",
"selected": "Sélectionné",
"selected_questions": "Questions sélectionnées",
"selection": "Sélection",
"selections": "Sélections",
"send_test_email": "Envoyer un e-mail de test",
"session_not_found": "Session non trouvée",
"settings": "Paramètres",
@@ -392,6 +393,7 @@
"show_response_count": "Afficher le nombre de réponses",
"shown": "Montré",
"size": "Taille",
"skip": "Ignorer",
"skipped": "Passé",
"skips": "Sauter",
"some_files_failed_to_upload": "Certains fichiers n'ont pas pu être téléchargés",
@@ -461,6 +463,7 @@
"website_survey": "Sondage de site web",
"weeks": "semaines",
"welcome_card": "Carte de bienvenue",
"workflows": "Workflows",
"workspace_configuration": "Configuration du projet",
"workspace_created_successfully": "Projet créé avec succès",
"workspace_creation_description": "Organisez les enquêtes dans des projets pour un meilleur contrôle d'accès.",
@@ -1756,7 +1759,6 @@
"welcome_message": "Message de bienvenue",
"without_a_filter_all_of_your_users_can_be_surveyed": "Sans filtre, tous vos utilisateurs peuvent être sondés.",
"you_have_not_created_a_segment_yet": "Tu n'as pas encore créé de segment.",
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "Vous devez avoir deux langues ou plus configurées dans votre espace de travail pour travailler avec les traductions.",
"your_description_here_recall_information_with": "Votre description ici. Rappelez-vous des informations avec @",
"your_question_here_recall_information_with": "Votre question ici. Rappelez-vous des informations avec @",
"your_web_app": "Votre application web",
@@ -2030,6 +2032,7 @@
"starts": "Commence",
"starts_tooltip": "Nombre de fois que l'enquête a été commencée.",
"survey_reset_successfully": "Réinitialisation du sondage réussie ! {responseCount} réponses et {displayCount} affichages ont été supprimés.",
"survey_results": "Résultats de {surveyName}",
"this_month": "Ce mois-ci",
"this_quarter": "Ce trimestre",
"this_year": "Cette année",
@@ -3258,5 +3261,18 @@
"usability_question_9_headline": "Je me suis senti confiant en utilisant le système.",
"usability_rating_description": "Mesurez la convivialité perçue en demandant aux utilisateurs d'évaluer leur expérience avec votre produit via un sondage standardisé de 10 questions.",
"usability_score_name": "Score d'Utilisabilité du Système (SUS)"
},
"workflows": {
"coming_soon_description": "Merci d'avoir partagé votre idée de workflow avec nous! Nous concevons actuellement cette fonctionnalité et vos retours nous aideront à créer exactement ce dont vous avez besoin.",
"coming_soon_title": "Nous y sommes presque!",
"follow_up_label": "Y a-t-il autre chose que vous aimeriez ajouter?",
"follow_up_placeholder": "Quelles tâches spécifiques aimeriez-vous automatiser? Des outils ou intégrations que vous souhaiteriez inclure?",
"generate_button": "Générer le workflow",
"heading": "Quel workflow souhaitez-vous créer?",
"placeholder": "Décrivez le workflow que vous souhaitez générer...",
"subheading": "Générez votre workflow en quelques secondes.",
"submit_button": "Ajouter des détails",
"thank_you_description": "Votre contribution nous aide à créer la fonctionnalité Workflows dont vous avez réellement besoin. Nous vous tiendrons informé de nos progrès.",
"thank_you_title": "Merci pour vos retours!"
}
}
+26 -10
View File
@@ -175,9 +175,11 @@
"copy": "Másolás",
"copy_code": "Kód másolása",
"copy_link": "Hivatkozás másolása",
"count_attributes": "{value, plural, one {{value} attribútum} other {{value} attribútum}}",
"count_contacts": "{value, plural, one {{value} partner} other {{value} partner}}",
"count_responses": "{value, plural, one {{value} válasz} other {{value} válasz}}",
"count_attributes": "{count, plural, one {{count} attribútum} other {{count} attribútum}}",
"count_contacts": "{count, plural, one {{count} partner} other {{count} partner}}",
"count_members": "{count, plural, one {{count} tag} other {{count} tag}}",
"count_responses": "{count, plural, one {{count} válasz} other {{count} válasz}}",
"count_selections": "{count, plural, one {{count} kijelölés} other {{count} kijelölés}}",
"create_new_organization": "Új szervezet létrehozása",
"create_segment": "Szakasz létrehozása",
"create_survey": "Kérdőív létrehozása",
@@ -207,6 +209,8 @@
"download": "Letöltés",
"draft": "Piszkozat",
"duplicate": "Kettőzés",
"duplicate_copy": "(másolat)",
"duplicate_copy_number": "({copyNumber}. másolat)",
"e_commerce": "E-kereskedelem",
"edit": "Szerkesztés",
"email": "E-mail",
@@ -274,7 +278,6 @@
"look_and_feel": "Megjelenés",
"manage": "Kezelés",
"marketing": "Marketing",
"member": "Tag",
"members": "Tagok",
"members_and_teams": "Tagok és csapatok",
"membership_not_found": "A tagság nem található",
@@ -382,8 +385,6 @@
"select_teams": "Csapatok kiválasztása",
"selected": "Kiválasztva",
"selected_questions": "Kiválasztott kérdések",
"selection": "Kiválasztás",
"selections": "Kiválasztások",
"send_test_email": "Teszt e-mail küldése",
"session_not_found": "A munkamenet nem található",
"settings": "Beállítások",
@@ -392,6 +393,7 @@
"show_response_count": "Válaszok számának megjelenítése",
"shown": "Megjelenítve",
"size": "Méret",
"skip": "Kihagyás",
"skipped": "Kihagyva",
"skips": "Kihagyja",
"some_files_failed_to_upload": "Néhány fájlt nem sikerült feltölteni",
@@ -461,6 +463,7 @@
"website_survey": "Webhely kérdőív",
"weeks": "hetek",
"welcome_card": "Üdvözlő kártya",
"workflows": "Munkafolyamatok",
"workspace_configuration": "Munkaterület beállítása",
"workspace_created_successfully": "A munkaterület sikeresen létrehozva",
"workspace_creation_description": "Kérdőívek munkaterületekre szervezése a jobb hozzáférés-vezérlés érdekében.",
@@ -1756,7 +1759,6 @@
"welcome_message": "Üdvözlő üzenet",
"without_a_filter_all_of_your_users_can_be_surveyed": "Szűrő nélkül az összes felhasználója megkérdezhető.",
"you_have_not_created_a_segment_yet": "Még nem hozott létre szakaszt",
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "Be kell állítania kettő vagy több nyelvet a munkaterületen a fordításokkal való munkához.",
"your_description_here_recall_information_with": "Ide jön a leírás. Információk visszahívása a @ karakterrel.",
"your_question_here_recall_information_with": "Ide jön a kérdés. Információk visszahívása a @ karakterrel.",
"your_web_app": "Saját webalkalmazás",
@@ -2030,6 +2032,7 @@
"starts": "Elkezdések",
"starts_tooltip": "A kérdőív elkezdési alkalmainak száma.",
"survey_reset_successfully": "A kérdőív sikeresen visszaállítva. {responseCount} válasz és {displayCount} megjelenítés lett törölve.",
"survey_results": "{surveyName} eredményei",
"this_month": "Ez a hónap",
"this_quarter": "Ez a negyedév",
"this_year": "Ez az év",
@@ -2460,7 +2463,7 @@
"career_development_survey_question_6_choice_2": "Igazgató",
"career_development_survey_question_6_choice_3": "Vezető igazgató",
"career_development_survey_question_6_choice_4": "Alelnök",
"career_development_survey_question_6_choice_5": "Igazgató",
"career_development_survey_question_6_choice_5": "Ügyvezető",
"career_development_survey_question_6_choice_6": "Egyéb",
"career_development_survey_question_6_headline": "Az alábbiak közül melyik írja le legjobban a jelenlegi munkája szintjét?",
"career_development_survey_question_6_subheader": "Válassza ki a következő lehetőségek egyikét:",
@@ -2973,7 +2976,7 @@
"onboarding_segmentation": "Beléptetés szakaszolása",
"onboarding_segmentation_description": "További információk azzal kapcsolatban, hogy kik regisztráltak a termékére és miért.",
"onboarding_segmentation_question_1_choice_1": "Alapító",
"onboarding_segmentation_question_1_choice_2": "Igazgató",
"onboarding_segmentation_question_1_choice_2": "Ügyvezető",
"onboarding_segmentation_question_1_choice_3": "Termékmenedzser",
"onboarding_segmentation_question_1_choice_4": "Terméktulajdonos",
"onboarding_segmentation_question_1_choice_5": "Szoftvermérnök",
@@ -3044,7 +3047,7 @@
"product_market_fit_superhuman_question_2_headline": "Mennyire lenne csalódott, ha többé nem használhatná a(z) $[projectName] projektet?",
"product_market_fit_superhuman_question_2_subheader": "Válassza ki a következő lehetőségek egyikét:",
"product_market_fit_superhuman_question_3_choice_1": "Alapító",
"product_market_fit_superhuman_question_3_choice_2": "Igazgató",
"product_market_fit_superhuman_question_3_choice_2": "Ügyvezető",
"product_market_fit_superhuman_question_3_choice_3": "Termékmenedzser",
"product_market_fit_superhuman_question_3_choice_4": "Terméktulajdonos",
"product_market_fit_superhuman_question_3_choice_5": "Szoftvermérnök",
@@ -3258,5 +3261,18 @@
"usability_question_9_headline": "Magabiztosnak éreztem magam a rendszer használata során.",
"usability_rating_description": "Az érzékelt használhatóság mérése arra kérve a felhasználókat, hogy értékeljék a termékkel kapcsolatos tapasztalataikat egy szabványosított, 10 kérdésből álló kérdőív használatával.",
"usability_score_name": "Rendszer-használhatósági pontszám (SUS)"
},
"workflows": {
"coming_soon_description": "Köszönjük, hogy megosztotta velünk a munkafolyamat ötletét! Jelenleg ezen a funkción dolgozunk, és a visszajelzése segít nekünk pontosan azt megépíteni, amire szüksége van.",
"coming_soon_title": "Majdnem kész vagyunk!",
"follow_up_label": "Van még valami, amit hozzá szeretne tenni?",
"follow_up_placeholder": "Milyen konkrét feladatokat szeretne automatizálni? Vannak olyan eszközök vagy integrációk, amelyeket szeretne belevenni?",
"generate_button": "Munkafolyamat generálása",
"heading": "Milyen munkafolyamatot szeretne létrehozni?",
"placeholder": "Írja le a munkafolyamatot, amelyet generálni szeretne...",
"subheading": "Generálja le a munkafolyamatát másodpercek alatt.",
"submit_button": "Részletek hozzáadása",
"thank_you_description": "A visszajelzése segít nekünk megépíteni azt a munkafolyamatok funkciót, amelyre tényleg szüksége van. Folyamatosan tájékoztatjuk az előrehaladásról.",
"thank_you_title": "Köszönjük a visszajelzését!"
}
}
+21 -5
View File
@@ -175,9 +175,11 @@
"copy": "コピー",
"copy_code": "コードをコピー",
"copy_link": "リンクをコピー",
"count_attributes": "{value, plural, other {{value}個の属性}}",
"count_attributes": "{count, plural, other {{count}個の属性}}",
"count_contacts": "{count, plural, other {# 件の連絡先}}",
"count_members": "{count, plural, other {{count}人のメンバー}}",
"count_responses": "{count, plural, other {# 件の回答}}",
"count_selections": "{count, plural, other {{count}件選択中}}",
"create_new_organization": "新しい組織を作成",
"create_segment": "セグメントを作成",
"create_survey": "フォームを作成",
@@ -207,6 +209,8 @@
"download": "ダウンロード",
"draft": "下書き",
"duplicate": "複製",
"duplicate_copy": "(コピー)",
"duplicate_copy_number": "(コピー {copyNumber})",
"e_commerce": "Eコマース",
"edit": "編集",
"email": "メールアドレス",
@@ -274,7 +278,6 @@
"look_and_feel": "デザイン",
"manage": "管理",
"marketing": "マーケティング",
"member": "メンバー",
"members": "メンバー",
"members_and_teams": "メンバー&チーム",
"membership_not_found": "メンバーシップが見つかりません",
@@ -382,8 +385,6 @@
"select_teams": "チームを選択",
"selected": "選択済み",
"selected_questions": "選択した質問",
"selection": "選択",
"selections": "選択",
"send_test_email": "テストメールを送信",
"session_not_found": "セッションが見つかりません",
"settings": "設定",
@@ -392,6 +393,7 @@
"show_response_count": "回答数を表示",
"shown": "表示済み",
"size": "サイズ",
"skip": "スキップ",
"skipped": "スキップ済み",
"skips": "スキップ数",
"some_files_failed_to_upload": "一部のファイルのアップロードに失敗しました",
@@ -461,6 +463,7 @@
"website_survey": "ウェブサイトフォーム",
"weeks": "週間",
"welcome_card": "ウェルカムカード",
"workflows": "ワークフロー",
"workspace_configuration": "ワークスペース設定",
"workspace_created_successfully": "ワークスペースが正常に作成されました",
"workspace_creation_description": "アクセス制御を改善するために、フォームをワークスペースで整理します。",
@@ -1756,7 +1759,6 @@
"welcome_message": "ウェルカムメッセージ",
"without_a_filter_all_of_your_users_can_be_surveyed": "フィルターがなければ、すべてのユーザーがフォームに回答できます。",
"you_have_not_created_a_segment_yet": "まだセグメントを作成していません",
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "翻訳を使用するには、ワークスペースに2つ以上の言語を設定する必要があります。",
"your_description_here_recall_information_with": "ここにあなたの説明。@ で情報を呼び出す",
"your_question_here_recall_information_with": "ここにあなたの質問。@ で情報を呼び出す",
"your_web_app": "あなたのウェブアプリ",
@@ -2030,6 +2032,7 @@
"starts": "開始",
"starts_tooltip": "フォームが開始された回数。",
"survey_reset_successfully": "フォームを正常にリセットしました!{responseCount} 件の回答と {displayCount} 件の表示が削除されました。",
"survey_results": "{surveyName}の結果",
"this_month": "今月",
"this_quarter": "今四半期",
"this_year": "今年",
@@ -3258,5 +3261,18 @@
"usability_question_9_headline": "システムを使っている間、自信がありました。",
"usability_rating_description": "標準化された10の質問アンケートを使用して、製品に対するユーザーの体験を評価し、知覚された使いやすさを測定する。",
"usability_score_name": "システムユーザビリティスコア(SUS)"
},
"workflows": {
"coming_soon_description": "ワークフローのアイデアを共有していただきありがとうございます!現在この機能を設計中で、あなたのフィードバックは私たちが必要とされる機能を構築するのに役立ちます。",
"coming_soon_title": "もうすぐ完成です!",
"follow_up_label": "他に追加したいことはありますか?",
"follow_up_placeholder": "どのような作業を自動化したいですか?含めたいツールや連携機能はありますか?",
"generate_button": "ワークフローを生成",
"heading": "どのようなワークフローを作成しますか?",
"placeholder": "生成したいワークフローを説明してください...",
"subheading": "数秒でワークフローを生成します。",
"submit_button": "詳細を追加",
"thank_you_description": "あなたの意見は、実際に必要とされるワークフロー機能の構築に役立ちます。進捗状況をお知らせします。",
"thank_you_title": "フィードバックありがとうございます!"
}
}
+23 -7
View File
@@ -175,9 +175,11 @@
"copy": "Kopiëren",
"copy_code": "Kopieer code",
"copy_link": "Kopieer link",
"count_attributes": "{value, plural, one {{value} attribuut} other {{value} attributen}}",
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacten}}",
"count_responses": "{value, plural, one {{value} reactie} other {{value} reacties}}",
"count_attributes": "{count, plural, one {{count} attribuut} other {{count} attributen}}",
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacten}}",
"count_members": "{count, plural, one {{count} lid} other {{count} leden}}",
"count_responses": "{count, plural, one {{count} reactie} other {{count} reacties}}",
"count_selections": "{count, plural, one {{count} selectie} other {{count} selecties}}",
"create_new_organization": "Creëer een nieuwe organisatie",
"create_segment": "Segment maken",
"create_survey": "Enquête maken",
@@ -207,6 +209,8 @@
"download": "Downloaden",
"draft": "Voorlopige versie",
"duplicate": "Duplicaat",
"duplicate_copy": "(kopie)",
"duplicate_copy_number": "(kopie {copyNumber})",
"e_commerce": "E-commerce",
"edit": "Bewerking",
"email": "E-mail",
@@ -274,7 +278,6 @@
"look_and_feel": "Kijk & voel",
"manage": "Beheren",
"marketing": "Marketing",
"member": "Lid",
"members": "Leden",
"members_and_teams": "Leden & teams",
"membership_not_found": "Lidmaatschap niet gevonden",
@@ -382,8 +385,6 @@
"select_teams": "Selecteer teams",
"selected": "Gekozen",
"selected_questions": "Geselecteerde vragen",
"selection": "Selectie",
"selections": "Selecties",
"send_test_email": "Test-e-mail verzenden",
"session_not_found": "Sessie niet gevonden",
"settings": "Instellingen",
@@ -392,6 +393,7 @@
"show_response_count": "Toon het aantal reacties",
"shown": "Getoond",
"size": "Maat",
"skip": "Overslaan",
"skipped": "Overgeslagen",
"skips": "Overslaan",
"some_files_failed_to_upload": "Sommige bestanden konden niet worden geüpload",
@@ -461,6 +463,7 @@
"website_survey": "Website-enquête",
"weeks": "weken",
"welcome_card": "Welkomstkaart",
"workflows": "Workflows",
"workspace_configuration": "Werkruimte-configuratie",
"workspace_created_successfully": "Project succesvol aangemaakt",
"workspace_creation_description": "Organiseer enquêtes in werkruimtes voor beter toegangsbeheer.",
@@ -1756,7 +1759,6 @@
"welcome_message": "Welkomstbericht",
"without_a_filter_all_of_your_users_can_be_surveyed": "Zonder filter kunnen al uw gebruikers worden bevraagd.",
"you_have_not_created_a_segment_yet": "U heeft nog geen segment aangemaakt",
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "Je moet twee of meer talen hebben ingesteld in je werkruimte om met vertalingen te kunnen werken.",
"your_description_here_recall_information_with": "Uw beschrijving hier. Roep informatie op met @",
"your_question_here_recall_information_with": "Uw vraag hier. Roep informatie op met @",
"your_web_app": "Uw web-app",
@@ -2030,6 +2032,7 @@
"starts": "Begint",
"starts_tooltip": "Aantal keren dat de enquête is gestart.",
"survey_reset_successfully": "Enquête opnieuw ingesteld! {responseCount} reacties en {displayCount} displays zijn verwijderd.",
"survey_results": "Resultaten van {surveyName}",
"this_month": "Deze maand",
"this_quarter": "Dit kwartaal",
"this_year": "Dit jaar",
@@ -3258,5 +3261,18 @@
"usability_question_9_headline": "Ik voelde me zelfverzekerd tijdens het gebruik van het systeem.",
"usability_rating_description": "Meet de waargenomen bruikbaarheid door gebruikers te vragen hun ervaring met uw product te beoordelen met behulp van een gestandaardiseerde enquête met tien vragen.",
"usability_score_name": "Systeembruikbaarheidsscore (SUS)"
},
"workflows": {
"coming_soon_description": "Bedankt voor het delen van je workflow-idee met ons! We zijn momenteel bezig met het ontwerpen van deze functie en jouw feedback helpt ons om precies te bouwen wat je nodig hebt.",
"coming_soon_title": "We zijn er bijna!",
"follow_up_label": "Is er nog iets dat je wilt toevoegen?",
"follow_up_placeholder": "Welke specifieke taken wil je automatiseren? Zijn er tools of integraties die je graag zou willen zien?",
"generate_button": "Genereer workflow",
"heading": "Welke workflow wil je maken?",
"placeholder": "Beschrijf de workflow die je wilt genereren...",
"subheading": "Genereer je workflow in enkele seconden.",
"submit_button": "Voeg details toe",
"thank_you_description": "Jouw input helpt ons om de Workflows-functie te bouwen die je echt nodig hebt. We houden je op de hoogte van onze voortgang.",
"thank_you_title": "Bedankt voor je feedback!"
}
}
+23 -7
View File
@@ -175,9 +175,11 @@
"copy": "Copiar",
"copy_code": "Copiar código",
"copy_link": "Copiar Link",
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
"count_contacts": "{value, plural, one {# contato} other {# contatos} }",
"count_responses": "{value, plural, other {# respostas}}",
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
"count_contacts": "{count, plural, one {# contato} other {# contatos} }",
"count_members": "{count, plural, one {{count} membro} other {{count} membros}}",
"count_responses": "{count, plural, other {# respostas}}",
"count_selections": "{count, plural, one {{count} seleção} other {{count} seleções}}",
"create_new_organization": "Criar nova organização",
"create_segment": "Criar segmento",
"create_survey": "Criar pesquisa",
@@ -207,6 +209,8 @@
"download": "baixar",
"draft": "Rascunho",
"duplicate": "Duplicar",
"duplicate_copy": "(cópia)",
"duplicate_copy_number": "(cópia {copyNumber})",
"e_commerce": "comércio eletrônico",
"edit": "Editar",
"email": "Email",
@@ -274,7 +278,6 @@
"look_and_feel": "Aparência e Experiência",
"manage": "gerenciar",
"marketing": "marketing",
"member": "Membros",
"members": "Membros",
"members_and_teams": "Membros e equipes",
"membership_not_found": "Assinatura não encontrada",
@@ -382,8 +385,6 @@
"select_teams": "Selecionar times",
"selected": "Selecionado",
"selected_questions": "Perguntas selecionadas",
"selection": "seleção",
"selections": "seleções",
"send_test_email": "Enviar e-mail de teste",
"session_not_found": "Sessão não encontrada",
"settings": "Configurações",
@@ -392,6 +393,7 @@
"show_response_count": "Mostrar contagem de respostas",
"shown": "mostrado",
"size": "Tamanho",
"skip": "Pular",
"skipped": "Pulou",
"skips": "Pula",
"some_files_failed_to_upload": "Alguns arquivos falharam ao enviar",
@@ -461,6 +463,7 @@
"website_survey": "Pesquisa de Site",
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workflows": "Fluxos de trabalho",
"workspace_configuration": "Configuração do projeto",
"workspace_created_successfully": "Projeto criado com sucesso",
"workspace_creation_description": "Organize pesquisas em projetos para melhor controle de acesso.",
@@ -1756,7 +1759,6 @@
"welcome_message": "Mensagem de boas-vindas",
"without_a_filter_all_of_your_users_can_be_surveyed": "Sem um filtro, todos os seus usuários podem ser pesquisados.",
"you_have_not_created_a_segment_yet": "Você ainda não criou um segmento.",
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "Você precisa ter dois ou mais idiomas configurados em seu espaço de trabalho para trabalhar com traduções.",
"your_description_here_recall_information_with": "Sua descrição aqui. Lembre-se de informações com @",
"your_question_here_recall_information_with": "Sua pergunta aqui. Lembre-se de informações com @",
"your_web_app": "Sua aplicação web",
@@ -2030,6 +2032,7 @@
"starts": "começa",
"starts_tooltip": "Número de vezes que a pesquisa foi iniciada.",
"survey_reset_successfully": "Pesquisa redefinida com sucesso! {responseCount} respostas e {displayCount} exibições foram deletadas.",
"survey_results": "Resultados de {surveyName}",
"this_month": "Este mês",
"this_quarter": "Este trimestre",
"this_year": "Este ano",
@@ -3258,5 +3261,18 @@
"usability_question_9_headline": "Me senti confiante ao usar o sistema.",
"usability_rating_description": "Meça a usabilidade percebida perguntando aos usuários para avaliar sua experiência com seu produto usando uma pesquisa padronizada de 10 perguntas.",
"usability_score_name": "Pontuação de Usabilidade do Sistema (SUS)"
},
"workflows": {
"coming_soon_description": "Obrigado por compartilhar sua ideia de fluxo de trabalho conosco! Estamos atualmente projetando este recurso e seu feedback nos ajudará a construir exatamente o que você precisa.",
"coming_soon_title": "Estamos quase lá!",
"follow_up_label": "Há algo mais que você gostaria de adicionar?",
"follow_up_placeholder": "Quais tarefas específicas você gostaria de automatizar? Alguma ferramenta ou integração que você gostaria de incluir?",
"generate_button": "Gerar fluxo de trabalho",
"heading": "Qual fluxo de trabalho você quer criar?",
"placeholder": "Descreva o fluxo de trabalho que você quer gerar...",
"subheading": "Gere seu fluxo de trabalho em segundos.",
"submit_button": "Adicionar detalhes",
"thank_you_description": "Sua contribuição nos ajuda a construir o recurso de Fluxos de trabalho que você realmente precisa. Manteremos você informado sobre nosso progresso.",
"thank_you_title": "Obrigado pelo seu feedback!"
}
}
+23 -7
View File
@@ -175,9 +175,11 @@
"copy": "Copiar",
"copy_code": "Copiar código",
"copy_link": "Copiar Link",
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
"count_contacts": "{value, plural, one {# contacto} other {# contactos} }",
"count_responses": "{value, plural, other {# respostas}}",
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
"count_contacts": "{count, plural, one {# contacto} other {# contactos} }",
"count_members": "{count, plural, one {{count} membro} other {{count} membros}}",
"count_responses": "{count, plural, other {# respostas}}",
"count_selections": "{count, plural, one {{count} seleção} other {{count} seleções}}",
"create_new_organization": "Criar nova organização",
"create_segment": "Criar segmento",
"create_survey": "Criar inquérito",
@@ -207,6 +209,8 @@
"download": "Transferir",
"draft": "Rascunho",
"duplicate": "Duplicar",
"duplicate_copy": "(cópia)",
"duplicate_copy_number": "(cópia {copyNumber})",
"e_commerce": "Comércio Eletrónico",
"edit": "Editar",
"email": "Email",
@@ -274,7 +278,6 @@
"look_and_feel": "Aparência e Sensação",
"manage": "Gerir",
"marketing": "Marketing",
"member": "Membro",
"members": "Membros",
"members_and_teams": "Membros e equipas",
"membership_not_found": "Associação não encontrada",
@@ -382,8 +385,6 @@
"select_teams": "Selecionar equipas",
"selected": "Selecionado",
"selected_questions": "Perguntas selecionadas",
"selection": "Seleção",
"selections": "Seleções",
"send_test_email": "Enviar email de teste",
"session_not_found": "Sessão não encontrada",
"settings": "Configurações",
@@ -392,6 +393,7 @@
"show_response_count": "Mostrar contagem de respostas",
"shown": "Mostrado",
"size": "Tamanho",
"skip": "Saltar",
"skipped": "Ignorado",
"skips": "Saltos",
"some_files_failed_to_upload": "Alguns ficheiros falharam ao carregar",
@@ -461,6 +463,7 @@
"website_survey": "Inquérito do Website",
"weeks": "semanas",
"welcome_card": "Cartão de boas-vindas",
"workflows": "Fluxos de trabalho",
"workspace_configuration": "Configuração do projeto",
"workspace_created_successfully": "Projeto criado com sucesso",
"workspace_creation_description": "Organize inquéritos em projetos para melhor controlo de acesso.",
@@ -1756,7 +1759,6 @@
"welcome_message": "Mensagem de boas-vindas",
"without_a_filter_all_of_your_users_can_be_surveyed": "Sem um filtro, todos os seus utilizadores podem ser pesquisados.",
"you_have_not_created_a_segment_yet": "Ainda não criou um segmento",
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "Precisa de ter dois ou mais idiomas configurados no seu espaço de trabalho para trabalhar com traduções.",
"your_description_here_recall_information_with": "A sua descrição aqui. Recorde a informação com @",
"your_question_here_recall_information_with": "A sua pergunta aqui. Recorde a informação com @",
"your_web_app": "A sua aplicação web",
@@ -2030,6 +2032,7 @@
"starts": "Começa",
"starts_tooltip": "Número de vezes que o inquérito foi iniciado.",
"survey_reset_successfully": "Inquérito reiniciado com sucesso! {responseCount} respostas e {displayCount} exibições foram eliminadas.",
"survey_results": "Resultados de {surveyName}",
"this_month": "Este mês",
"this_quarter": "Este trimestre",
"this_year": "Este ano",
@@ -3258,5 +3261,18 @@
"usability_question_9_headline": "Eu senti-me confiante ao usar o sistema.",
"usability_rating_description": "Meça a usabilidade percebida ao solicitar que os utilizadores avaliem a sua experiência com o seu produto usando um questionário padronizado de 10 perguntas.",
"usability_score_name": "System Usability Score (SUS)"
},
"workflows": {
"coming_soon_description": "Obrigado por partilhar a sua ideia de fluxo de trabalho connosco! Estamos atualmente a desenhar esta funcionalidade e o seu feedback vai ajudar-nos a construir exatamente o que precisa.",
"coming_soon_title": "Estamos quase lá!",
"follow_up_label": "Há mais alguma coisa que gostaria de acrescentar?",
"follow_up_placeholder": "Que tarefas específicas gostaria de automatizar? Alguma ferramenta ou integração que gostaria de incluir?",
"generate_button": "Gerar fluxo de trabalho",
"heading": "Que fluxo de trabalho quer criar?",
"placeholder": "Descreva o fluxo de trabalho que quer gerar...",
"subheading": "Gere o seu fluxo de trabalho em segundos.",
"submit_button": "Adicionar detalhes",
"thank_you_description": "A sua contribuição ajuda-nos a construir a funcionalidade de Fluxos de trabalho de que realmente precisa. Vamos mantê-lo informado sobre o nosso progresso.",
"thank_you_title": "Obrigado pelo seu feedback!"
}
}
+23 -7
View File
@@ -175,9 +175,11 @@
"copy": "Copiază",
"copy_code": "Copiază codul",
"copy_link": "Copiază legătura",
"count_attributes": "{value, plural, one {{value} atribut} few {{value} atribute} other {{value} de atribute}}",
"count_contacts": "{value, plural, one {# contact} other {# contacte} }",
"count_responses": "{value, plural, one {# răspuns} other {# răspunsuri} }",
"count_attributes": "{count, plural, one {{count} atribut} few {{count} atribute} other {{count} de atribute}}",
"count_contacts": "{count, plural, one {# contact} other {# contacte} }",
"count_members": "{count, plural, one {{count} membru} few {{count} membri} other {{count} de membri}}",
"count_responses": "{count, plural, one {# răspuns} other {# răspunsuri} }",
"count_selections": "{count, plural, one {{count} selecție} few {{count} selecții} other {{count} de selecții}}",
"create_new_organization": "Creează organizație nouă",
"create_segment": "Creați segment",
"create_survey": "Creează sondaj",
@@ -207,6 +209,8 @@
"download": "Descărcare",
"draft": "Schiță",
"duplicate": "Duplicități",
"duplicate_copy": "(copie)",
"duplicate_copy_number": "(copie {copyNumber})",
"e_commerce": "Comerț electronic",
"edit": "Editare",
"email": "Email",
@@ -274,7 +278,6 @@
"look_and_feel": "Aspect și Comportament",
"manage": "Gestionați",
"marketing": "Marketing",
"member": "Membru",
"members": "Membri",
"members_and_teams": "Membri și echipe",
"membership_not_found": "Apartenența nu a fost găsită",
@@ -382,8 +385,6 @@
"select_teams": "Selectați echipele",
"selected": "Selectat",
"selected_questions": "Întrebări selectate",
"selection": "Selecție",
"selections": "Selecții",
"send_test_email": "Trimite email de test",
"session_not_found": "Sesiune inexistentă",
"settings": "Setări",
@@ -392,6 +393,7 @@
"show_response_count": "Afișează numărul de răspunsuri",
"shown": "Afișat",
"size": "Mărime",
"skip": "Omite",
"skipped": "Sărit",
"skips": "Salturi",
"some_files_failed_to_upload": "Unele fișiere nu au reușit să se încarce",
@@ -461,6 +463,7 @@
"website_survey": "Chestionar despre site",
"weeks": "săptămâni",
"welcome_card": "Card de bun venit",
"workflows": "Workflows",
"workspace_configuration": "Configurare workspace",
"workspace_created_successfully": "Spațiul de lucru a fost creat cu succes",
"workspace_creation_description": "Organizează sondajele în workspaces pentru un control mai bun al accesului.",
@@ -1756,7 +1759,6 @@
"welcome_message": "Mesaj de bun venit",
"without_a_filter_all_of_your_users_can_be_surveyed": "Fără un filtru, toți utilizatorii pot fi chestionați.",
"you_have_not_created_a_segment_yet": "Nu ai creat încă un segment",
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "Trebuie să aveți cel puțin două limbi configurate în spațiul de lucru pentru a putea lucra cu traduceri.",
"your_description_here_recall_information_with": "Descrierea ta aici. Reamintiți informațiile cu @",
"your_question_here_recall_information_with": "Întrebarea ta aici. Reamintiți informațiile cu @",
"your_web_app": "Aplicația dumneavoastră web",
@@ -2030,6 +2032,7 @@
"starts": "Începuturi",
"starts_tooltip": "Număr de ori când sondajul a fost început.",
"survey_reset_successfully": "Resetarea chestionarului realizată cu succes! Au fost șterse {responseCount} răspunsuri și {displayCount} afișări.",
"survey_results": "Rezultatele {surveyName}",
"this_month": "Luna aceasta",
"this_quarter": "Trimestrul acesta",
"this_year": "Anul acesta",
@@ -3258,5 +3261,18 @@
"usability_question_9_headline": "M-am simțit încrezător în timp ce utilizam sistemul.",
"usability_rating_description": "Măsurați uzabilitatea percepută cerând utilizatorilor să își evalueze experiența cu produsul dumneavoastră folosind un chestionar standardizat din 10 întrebări.",
"usability_score_name": "Scor de Uzabilitate al Sistemului (SUS)"
},
"workflows": {
"coming_soon_description": "Îți mulțumim că ai împărtășit cu noi ideea ta de workflow! În prezent, lucrăm la această funcționalitate, iar feedback-ul tău ne ajută să construim exact ce ai nevoie.",
"coming_soon_title": "Suntem aproape gata!",
"follow_up_label": "Mai este ceva ce ai vrea să adaugi?",
"follow_up_placeholder": "Ce sarcini specifice ai vrea să automatizezi? Există instrumente sau integrări pe care le-ai dori incluse?",
"generate_button": "Generează workflow",
"heading": "Ce workflow vrei să creezi?",
"placeholder": "Descrie workflow-ul pe care vrei să-l generezi...",
"subheading": "Generează-ți workflow-ul în câteva secunde.",
"submit_button": "Adaugă detalii",
"thank_you_description": "Contribuția ta ne ajută să construim funcția Workflows de care chiar ai nevoie. Te vom ține la curent cu progresul nostru.",
"thank_you_title": "Îți mulțumim pentru feedback!"
}
}
+23 -7
View File
@@ -175,9 +175,11 @@
"copy": "Копировать",
"copy_code": "Скопировать код",
"copy_link": "Скопировать ссылку",
"count_attributes": "{value, plural, one {{value} атрибут} few {{value} атрибута} many {{value} атрибутов} other {{value} атрибута}}",
"count_contacts": "{value, plural, one {{value} контакт} few {{value} контакта} many {{value} контактов} other {{value} контактов}}",
"count_responses": "{value, plural, one {{value} ответ} few {{value} ответа} many {{value} ответов} other {{value} ответов}}",
"count_attributes": "{count, plural, one {{count} атрибут} few {{count} атрибута} many {{count} атрибутов} other {{count} атрибута}}",
"count_contacts": "{count, plural, one {{count} контакт} few {{count} контакта} many {{count} контактов} other {{count} контактов}}",
"count_members": "{count, plural, one {{count} участник} few {{count} участника} many {{count} участников} other {{count} участника}}",
"count_responses": "{count, plural, one {{count} ответ} few {{count} ответа} many {{count} ответов} other {{count} ответов}}",
"count_selections": "{count, plural, one {{count} выбран} few {{count} выбрано} many {{count} выбрано} other {{count} выбрано}}",
"create_new_organization": "Создать новую организацию",
"create_segment": "Создать сегмент",
"create_survey": "Создать опрос",
@@ -207,6 +209,8 @@
"download": "Скачать",
"draft": "Черновик",
"duplicate": "Дублировать",
"duplicate_copy": "(копия)",
"duplicate_copy_number": "(копия {copyNumber})",
"e_commerce": "E-Commerce",
"edit": "Редактировать",
"email": "Email",
@@ -274,7 +278,6 @@
"look_and_feel": "Внешний вид",
"manage": "Управление",
"marketing": "Маркетинг",
"member": "Участник",
"members": "Участники",
"members_and_teams": "Участники и команды",
"membership_not_found": "Участие не найдено",
@@ -382,8 +385,6 @@
"select_teams": "Выбрать команды",
"selected": "Выбрано",
"selected_questions": "Выбранные вопросы",
"selection": "Выбор",
"selections": "Выборы",
"send_test_email": "Отправить тестовое письмо",
"session_not_found": "Сессия не найдена",
"settings": "Настройки",
@@ -392,6 +393,7 @@
"show_response_count": "Показать количество ответов",
"shown": "Показано",
"size": "Размер",
"skip": "Пропустить",
"skipped": "Пропущено",
"skips": "Пропуски",
"some_files_failed_to_upload": "Не удалось загрузить некоторые файлы",
@@ -461,6 +463,7 @@
"website_survey": "Опрос сайта",
"weeks": "недели",
"welcome_card": "Приветственная карточка",
"workflows": "Воркфлоу",
"workspace_configuration": "Настройка рабочего пространства",
"workspace_created_successfully": "Рабочий проект успешно создан",
"workspace_creation_description": "Организуйте опросы в рабочих пространствах для лучшего контроля доступа.",
@@ -1756,7 +1759,6 @@
"welcome_message": "Приветственное сообщение",
"without_a_filter_all_of_your_users_can_be_surveyed": "Без фильтра все ваши пользователи могут быть опрошены.",
"you_have_not_created_a_segment_yet": "Вы ещё не создали сегмент",
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "Для работы с переводами необходимо настроить два или более языков в рабочем пространстве.",
"your_description_here_recall_information_with": "Ваша инструкция здесь. Вспомните информацию с помощью @",
"your_question_here_recall_information_with": "Ваш вопрос здесь. Вспомните информацию с помощью @",
"your_web_app": "Ваше веб-приложение",
@@ -2030,6 +2032,7 @@
"starts": "Запуски",
"starts_tooltip": "Количество запусков опроса.",
"survey_reset_successfully": "Опрос успешно сброшен! {responseCount} ответов и {displayCount} показов были удалены.",
"survey_results": "Результаты {surveyName}",
"this_month": "В этом месяце",
"this_quarter": "В этом квартале",
"this_year": "В этом году",
@@ -3258,5 +3261,18 @@
"usability_question_9_headline": "Я чувствовал себя уверенно, используя систему.",
"usability_rating_description": "Оцените воспринимаемую удобство, попросив пользователей оценить свой опыт работы с вашим продуктом с помощью стандартизированного опроса из 10 вопросов.",
"usability_score_name": "Индекс удобства системы (SUS)"
},
"workflows": {
"coming_soon_description": "Спасибо, что поделился своей идеей воркфлоу с нами! Сейчас мы разрабатываем эту функцию, и твой отзыв поможет нам сделать именно то, что тебе нужно.",
"coming_soon_title": "Мы почти готовы!",
"follow_up_label": "Хочешь что-то ещё добавить?",
"follow_up_placeholder": "Какие задачи ты хочешь автоматизировать? Какие инструменты или интеграции тебе нужны?",
"generate_button": "Сгенерировать воркфлоу",
"heading": "Какой воркфлоу ты хочешь создать?",
"placeholder": "Опиши воркфлоу, который хочешь сгенерировать...",
"subheading": "Сгенерируй свой воркфлоу за секунды.",
"submit_button": "Добавить детали",
"thank_you_description": "Твои идеи помогают нам создавать воркфлоу, которые действительно нужны. Мы будем держать тебя в курсе нашего прогресса.",
"thank_you_title": "Спасибо за твой отзыв!"
}
}
+23 -7
View File
@@ -175,9 +175,11 @@
"copy": "Kopiera",
"copy_code": "Kopiera kod",
"copy_link": "Kopiera länk",
"count_attributes": "{value, plural, one {{value} attribut} other {{value} attribut}}",
"count_contacts": "{value, plural, one {{value} kontakt} other {{value} kontakter}}",
"count_responses": "{value, plural, one {{value} svar} other {{value} svar}}",
"count_attributes": "{count, plural, one {{count} attribut} other {{count} attribut}}",
"count_contacts": "{count, plural, one {{count} kontakt} other {{count} kontakter}}",
"count_members": "{count, plural, one {{count} medlem} other {{count} medlemmar}}",
"count_responses": "{count, plural, one {{count} svar} other {{count} svar}}",
"count_selections": "{count, plural, one {{count} val} other {{count} val}}",
"create_new_organization": "Skapa ny organisation",
"create_segment": "Skapa segment",
"create_survey": "Skapa enkät",
@@ -207,6 +209,8 @@
"download": "Ladda ner",
"draft": "Utkast",
"duplicate": "Duplicera",
"duplicate_copy": "(kopia)",
"duplicate_copy_number": "(kopia {copyNumber})",
"e_commerce": "E-handel",
"edit": "Redigera",
"email": "E-post",
@@ -274,7 +278,6 @@
"look_and_feel": "Utseende",
"manage": "Hantera",
"marketing": "Marknadsföring",
"member": "Medlem",
"members": "Medlemmar",
"members_and_teams": "Medlemmar och team",
"membership_not_found": "Medlemskap hittades inte",
@@ -382,8 +385,6 @@
"select_teams": "Välj team",
"selected": "Vald",
"selected_questions": "Valda frågor",
"selection": "Urval",
"selections": "Urval",
"send_test_email": "Skicka testmeddelande",
"session_not_found": "Session hittades inte",
"settings": "Inställningar",
@@ -392,6 +393,7 @@
"show_response_count": "Visa antal svar",
"shown": "Visad",
"size": "Storlek",
"skip": "Hoppa över",
"skipped": "Överhoppad",
"skips": "Överhoppningar",
"some_files_failed_to_upload": "Några filer misslyckades att laddas upp",
@@ -461,6 +463,7 @@
"website_survey": "Webbplatsenkät",
"weeks": "veckor",
"welcome_card": "Välkomstkort",
"workflows": "Arbetsflöden",
"workspace_configuration": "Arbetsytans konfiguration",
"workspace_created_successfully": "Arbetsytan har skapats",
"workspace_creation_description": "Organisera enkäter i arbetsytor för bättre åtkomstkontroll.",
@@ -1756,7 +1759,6 @@
"welcome_message": "Välkomstmeddelande",
"without_a_filter_all_of_your_users_can_be_surveyed": "Utan ett filter kan alla dina användare enkäteras.",
"you_have_not_created_a_segment_yet": "Du har inte skapat ett segment ännu",
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "Du måste ha två eller fler språk inställda i din arbetsyta för att kunna arbeta med översättningar.",
"your_description_here_recall_information_with": "Din beskrivning här. Återkalla information med @",
"your_question_here_recall_information_with": "Din fråga här. Återkalla information med @",
"your_web_app": "Din webbapp",
@@ -2030,6 +2032,7 @@
"starts": "Starter",
"starts_tooltip": "Antal gånger enkäten har startats.",
"survey_reset_successfully": "Enkät återställd! {responseCount} svar och {displayCount} visningar togs bort.",
"survey_results": "Resultat för {surveyName}",
"this_month": "Denna månad",
"this_quarter": "Detta kvartal",
"this_year": "Detta år",
@@ -3258,5 +3261,18 @@
"usability_question_9_headline": "Jag kände mig trygg när jag använde systemet.",
"usability_rating_description": "Mät upplevd användbarhet genom att be användare betygsätta sin upplevelse med din produkt med en standardiserad 10-frågors enkät.",
"usability_score_name": "System Usability Score (SUS)"
},
"workflows": {
"coming_soon_description": "Tack för att du delade din arbetsflödesidé med oss! Vi håller just nu på att designa den här funktionen och din feedback hjälper oss att bygga precis det du behöver.",
"coming_soon_title": "Vi är nästan där!",
"follow_up_label": "Är det något mer du vill lägga till?",
"follow_up_placeholder": "Vilka specifika uppgifter vill du automatisera? Finns det några verktyg eller integrationer du vill ha med?",
"generate_button": "Skapa arbetsflöde",
"heading": "Vilket arbetsflöde vill du skapa?",
"placeholder": "Beskriv arbetsflödet du vill skapa...",
"subheading": "Skapa ditt arbetsflöde på några sekunder.",
"submit_button": "Lägg till detaljer",
"thank_you_description": "Din input hjälper oss att bygga arbetsflödesfunktionen du faktiskt behöver. Vi håller dig uppdaterad om hur det går.",
"thank_you_title": "Tack för din feedback!"
}
}
+23 -7
View File
@@ -175,9 +175,11 @@
"copy": "复制",
"copy_code": "复制 代码",
"copy_link": "复制 链接",
"count_attributes": "{value, plural, one {{value} 个属性} other {{value} 个属性}}",
"count_contacts": "{value, plural, other {{value} 联系人} }",
"count_responses": "{value, plural, other {{value} 回复} }",
"count_attributes": "{count, plural, one {{count} 个属性} other {{count} 个属性}}",
"count_contacts": "{count, plural, other {{count} 联系人} }",
"count_members": "{count, plural, one {{count} 位成员} other {{count} 位成员}}",
"count_responses": "{count, plural, other {{count} 回复} }",
"count_selections": "{count, plural, other {已选择{count}项}}",
"create_new_organization": "创建 新的 组织",
"create_segment": "创建 细分",
"create_survey": "创建 调查",
@@ -207,6 +209,8 @@
"download": "下载",
"draft": "草稿",
"duplicate": "复制",
"duplicate_copy": "(副本)",
"duplicate_copy_number": "(副本 {copyNumber}",
"e_commerce": "电子商务",
"edit": "编辑",
"email": "邮箱",
@@ -274,7 +278,6 @@
"look_and_feel": "外观 & 感觉",
"manage": "管理",
"marketing": "市场营销",
"member": "成员",
"members": "成员",
"members_and_teams": "成员和团队",
"membership_not_found": "未找到会员资格",
@@ -382,8 +385,6 @@
"select_teams": "选择 团队",
"selected": "已选择",
"selected_questions": "选择的问题",
"selection": "选择",
"selections": "选择",
"send_test_email": "发送 测试 电子邮件",
"session_not_found": "会话 未找到",
"settings": "设置",
@@ -392,6 +393,7 @@
"show_response_count": "显示 响应 计数",
"shown": "显示",
"size": "尺寸",
"skip": "跳过",
"skipped": "跳过",
"skips": "跳过",
"some_files_failed_to_upload": "某些文件上传失败",
@@ -461,6 +463,7 @@
"website_survey": "网站 调查",
"weeks": "周",
"welcome_card": "欢迎 卡片",
"workflows": "工作流",
"workspace_configuration": "工作区配置",
"workspace_created_successfully": "工作区创建成功",
"workspace_creation_description": "在工作区中组织调查,以便更好地进行访问控制。",
@@ -1756,7 +1759,6 @@
"welcome_message": "欢迎 信息",
"without_a_filter_all_of_your_users_can_be_surveyed": "没有 过滤器 时 ,所有 用户 都可以 被 调查 。",
"you_have_not_created_a_segment_yet": "您 还没有 创建 段落",
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "要使用翻译功能,您的工作区需设置两种或以上语言。",
"your_description_here_recall_information_with": "在此输入描述。 调用信息与 @",
"your_question_here_recall_information_with": "在此输入你的问题。 调用信息与 @",
"your_web_app": "您的 网页应用",
@@ -2030,6 +2032,7 @@
"starts": "开始",
"starts_tooltip": "调查 被 开始 的 次数",
"survey_reset_successfully": "调查已重置成功!{responseCount} 个 反馈 和 {displayCount} 个 显示 已删除。",
"survey_results": "{surveyName} 结果",
"this_month": "本月",
"this_quarter": "本季度",
"this_year": "今年",
@@ -3258,5 +3261,18 @@
"usability_question_9_headline": "使用 系统 时 我 感到 自信。",
"usability_rating_description": "通过要求用户使用标准化的 10 问 调查 来 评价 他们对您产品的体验,以 测量 感知 的 可用性。",
"usability_score_name": "系统 可用性 得分 ( SUS )"
},
"workflows": {
"coming_soon_description": "感谢你与我们分享你的工作流想法!我们目前正在设计这个功能,你的反馈将帮助我们打造真正适合你的工具。",
"coming_soon_title": "我们快完成啦!",
"follow_up_label": "你还有其他想补充的吗?",
"follow_up_placeholder": "你希望自动化哪些具体任务?有没有想要集成的工具或服务?",
"generate_button": "生成工作流",
"heading": "你想创建什么样的工作流?",
"placeholder": "描述一下你想生成的工作流……",
"subheading": "几秒钟生成你的工作流。",
"submit_button": "补充细节",
"thank_you_description": "你的意见帮助我们打造真正适合你的工作流功能。我们会及时向你汇报进展。",
"thank_you_title": "感谢你的反馈!"
}
}
+23 -7
View File
@@ -175,9 +175,11 @@
"copy": "複製",
"copy_code": "複製程式碼",
"copy_link": "複製連結",
"count_attributes": "{value, plural, one {{value} 個屬性} other {{value} 個屬性}}",
"count_contacts": "{value, plural, other {{value} 聯絡人} }",
"count_responses": "{value, plural, other {{value} 回應} }",
"count_attributes": "{count, plural, one {{count} 個屬性} other {{count} 個屬性}}",
"count_contacts": "{count, plural, other {{count} 聯絡人} }",
"count_members": "{count, plural, one {{count} 位成員} other {{count} 位成員}}",
"count_responses": "{count, plural, other {{count} 回應} }",
"count_selections": "{count, plural, one {{count} 個選項} other {{count} 個選項}}",
"create_new_organization": "建立新組織",
"create_segment": "建立區隔",
"create_survey": "建立問卷",
@@ -207,6 +209,8 @@
"download": "下載",
"draft": "草稿",
"duplicate": "複製",
"duplicate_copy": "(複製)",
"duplicate_copy_number": "(複製 {copyNumber}",
"e_commerce": "電子商務",
"edit": "編輯",
"email": "電子郵件",
@@ -274,7 +278,6 @@
"look_and_feel": "外觀與風格",
"manage": "管理",
"marketing": "行銷",
"member": "成員",
"members": "成員",
"members_and_teams": "成員與團隊",
"membership_not_found": "找不到成員資格",
@@ -382,8 +385,6 @@
"select_teams": "選擇 團隊",
"selected": "已選取",
"selected_questions": "選取的問題",
"selection": "選取",
"selections": "選取",
"send_test_email": "發送測試電子郵件",
"session_not_found": "找不到工作階段",
"settings": "設定",
@@ -392,6 +393,7 @@
"show_response_count": "顯示回應數",
"shown": "已顯示",
"size": "大小",
"skip": "略過",
"skipped": "已跳過",
"skips": "跳過次數",
"some_files_failed_to_upload": "部分檔案上傳失敗",
@@ -461,6 +463,7 @@
"website_survey": "網站問卷",
"weeks": "週",
"welcome_card": "歡迎卡片",
"workflows": "工作流程",
"workspace_configuration": "工作區設定",
"workspace_created_successfully": "工作區已成功建立",
"workspace_creation_description": "將問卷組織在工作區中,以便更好地控管存取權限。",
@@ -1756,7 +1759,6 @@
"welcome_message": "歡迎訊息",
"without_a_filter_all_of_your_users_can_be_surveyed": "如果沒有篩選器,則可以調查您的所有使用者。",
"you_have_not_created_a_segment_yet": "您尚未建立區隔",
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "您必須在工作區中設定兩種或以上語言,才能進行翻譯作業。",
"your_description_here_recall_information_with": "您的描述在這裡。使用 @ 回憶資訊",
"your_question_here_recall_information_with": "您的問題在這裡。使用 @ 回憶資訊",
"your_web_app": "您的 Web 應用程式",
@@ -2030,6 +2032,7 @@
"starts": "開始次數",
"starts_tooltip": "問卷已開始的次數。",
"survey_reset_successfully": "調查 重置 成功!{responseCount} 條回應和 {displayCount} 個顯示被刪除。",
"survey_results": "{surveyName} 結果",
"this_month": "本月",
"this_quarter": "本季",
"this_year": "今年",
@@ -3258,5 +3261,18 @@
"usability_question_9_headline": "使用 系統 時,我 感到 有 信心。",
"usability_rating_description": "透過使用標準化的 十個問題 問卷,要求使用者評估他們對 您 產品的使用體驗,來衡量感知的 可用性。",
"usability_score_name": "系統 可用性 分數 (SUS)"
},
"workflows": {
"coming_soon_description": "感謝你和我們分享你的工作流程想法!我們目前正在設計這個功能,你的回饋將幫助我們打造真正符合你需求的工具。",
"coming_soon_title": "快完成囉!",
"follow_up_label": "還有什麼想補充的嗎?",
"follow_up_placeholder": "你想自動化哪些具體任務?有沒有想要整合的工具或服務?",
"generate_button": "產生工作流程",
"heading": "你想建立什麼樣的工作流程?",
"placeholder": "請描述你想產生的工作流程⋯⋯",
"subheading": "幾秒鐘就能產生你的工作流程。",
"submit_button": "補充細節",
"thank_you_description": "你的意見幫助我們打造真正符合你需求的工作流程功能。我們會隨時更新進度給你。",
"thank_you_title": "感謝你的回饋!"
}
}
@@ -23,11 +23,17 @@ const ZCreateTagAction = z.object({
tagName: z.string(),
});
export const createTagAction = authenticatedActionClient.schema(ZCreateTagAction).action(
export const createTagAction = authenticatedActionClient.inputSchema(ZCreateTagAction).action(
withAuditLogging(
"created",
"tag",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
async ({
parsedInput,
ctx,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateTagAction>;
}) => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
@@ -65,103 +71,125 @@ const ZCreateTagToResponseAction = z.object({
tagId: ZId,
});
export const createTagToResponseAction = authenticatedActionClient.schema(ZCreateTagToResponseAction).action(
withAuditLogging(
"addedToResponse",
"tag",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId);
const tagEnvironment = await getTag(parsedInput.tagId);
export const createTagToResponseAction = authenticatedActionClient
.inputSchema(ZCreateTagToResponseAction)
.action(
withAuditLogging(
"addedToResponse",
"tag",
async ({
parsedInput,
ctx,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateTagToResponseAction>;
}) => {
const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId);
const tagEnvironment = await getTag(parsedInput.tagId);
if (!responseEnvironmentId || !tagEnvironment) {
throw new Error("Environment not found");
if (!responseEnvironmentId || !tagEnvironment) {
throw new Error("Environment not found");
}
if (responseEnvironmentId !== tagEnvironment.environmentId) {
throw new Error("Response and tag are not in the same environment");
}
const organizationId = await getOrganizationIdFromEnvironmentId(responseEnvironmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.tagId = parsedInput.tagId;
const result = await addTagToRespone(parsedInput.responseId, parsedInput.tagId);
ctx.auditLoggingCtx.newObject = result;
return result;
}
if (responseEnvironmentId !== tagEnvironment.environmentId) {
throw new Error("Response and tag are not in the same environment");
}
const organizationId = await getOrganizationIdFromEnvironmentId(responseEnvironmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.tagId = parsedInput.tagId;
const result = await addTagToRespone(parsedInput.responseId, parsedInput.tagId);
ctx.auditLoggingCtx.newObject = result;
return result;
}
)
);
)
);
const ZDeleteTagOnResponseAction = z.object({
responseId: ZId,
tagId: ZId,
});
export const deleteTagOnResponseAction = authenticatedActionClient.schema(ZDeleteTagOnResponseAction).action(
withAuditLogging(
"removedFromResponse",
"tag",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId);
const tagEnvironment = await getTag(parsedInput.tagId);
const organizationId = await getOrganizationIdFromResponseId(parsedInput.responseId);
if (!responseEnvironmentId || !tagEnvironment) {
throw new Error("Environment not found");
}
export const deleteTagOnResponseAction = authenticatedActionClient
.inputSchema(ZDeleteTagOnResponseAction)
.action(
withAuditLogging(
"removedFromResponse",
"tag",
async ({
parsedInput,
ctx,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDeleteTagOnResponseAction>;
}) => {
const responseEnvironmentId = await getEnvironmentIdFromResponseId(parsedInput.responseId);
const tagEnvironment = await getTag(parsedInput.tagId);
const organizationId = await getOrganizationIdFromResponseId(parsedInput.responseId);
if (!responseEnvironmentId || !tagEnvironment) {
throw new Error("Environment not found");
}
if (responseEnvironmentId !== tagEnvironment.environmentId) {
throw new Error("Response and tag are not in the same environment");
}
if (responseEnvironmentId !== tagEnvironment.environmentId) {
throw new Error("Response and tag are not in the same environment");
}
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.tagId = parsedInput.tagId;
const result = await deleteTagOnResponse(parsedInput.responseId, parsedInput.tagId);
ctx.auditLoggingCtx.oldObject = result;
return result;
}
)
);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(responseEnvironmentId),
minPermission: "readWrite",
},
],
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.tagId = parsedInput.tagId;
const result = await deleteTagOnResponse(parsedInput.responseId, parsedInput.tagId);
ctx.auditLoggingCtx.oldObject = result;
return result;
}
)
);
const ZDeleteResponseAction = z.object({
responseId: ZId,
decrementQuotas: z.boolean().default(false),
decrementQuotas: z.boolean().prefault(false),
});
export const deleteResponseAction = authenticatedActionClient.schema(ZDeleteResponseAction).action(
export const deleteResponseAction = authenticatedActionClient.inputSchema(ZDeleteResponseAction).action(
withAuditLogging(
"deleted",
"response",
async ({ parsedInput, ctx }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
async ({
parsedInput,
ctx,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDeleteResponseAction>;
}) => {
const organizationId = await getOrganizationIdFromResponseId(parsedInput.responseId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -192,7 +220,7 @@ const ZGetResponseAction = z.object({
});
export const getResponseAction = authenticatedActionClient
.schema(ZGetResponseAction)
.inputSchema(ZGetResponseAction)
.action(async ({ parsedInput, ctx }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -42,7 +42,7 @@ export const SingleResponseCardBody = ({
return (
<span
key={index}
className="mr-0.5 ml-0.5 rounded-md border border-slate-200 bg-slate-50 px-1 py-0.5 text-sm first:ml-0">
className="ml-0.5 mr-0.5 rounded-md border border-slate-200 bg-slate-50 px-1 py-0.5 text-sm first:ml-0">
@{part}
</span>
);
@@ -1,22 +1,23 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZOverallHealthStatus = z
.object({
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,
}),
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"),
})
.openapi({
.meta({
title: "Health Check Response",
description: "Health check status for critical application dependencies",
});
})
.describe("Health check status for critical application dependencies");
export type OverallHealthStatus = z.infer<typeof ZOverallHealthStatus>;
@@ -1,26 +1,22 @@
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()
.openapi({
ref: "contactAttributeKeyId",
description: "The ID of the contact attribute key",
.meta({
id: "contactAttributeKeyId",
param: {
name: "id",
in: "path",
},
});
})
.describe("The ID of the contact attribute key");
export const ZContactAttributeKeyUpdateSchema = ZContactAttributeKey.pick({
name: true,
description: true,
}).openapi({
ref: "contactAttributeKeyUpdate",
}).meta({
id: "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.sourceType(),
query: ZGetContactAttributeKeysFilter,
},
responses: {
"200": {
@@ -17,7 +17,7 @@ export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
query: ZGetContactAttributeKeysFilter.sourceType(),
query: ZGetContactAttributeKeysFilter,
},
handler: async ({ authentication, parsedInput }) => {
const { query } = parsedInput;
@@ -49,7 +49,7 @@ export const POST = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
body: ZContactAttributeKeyInput.sourceType(),
body: ZContactAttributeKeyInput,
},
handler: async ({ authentication, parsedInput, auditLog }) => {
const { body } = parsedInput;
@@ -1,13 +1,10 @@
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.string().cuid2().optional().describe("The environment ID to filter by"),
environmentId: z.cuid2().optional().describe("The environment ID to filter by"),
})
.refine(
(data) => {
@@ -37,15 +34,15 @@ export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
// Enforce safe identifier format for key
if (!isSafeIdentifier(data.key)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: "custom",
message:
"Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
path: ["key"],
});
}
})
.openapi({
ref: "contactAttributeKeyInput",
.meta({
id: "contactAttributeKeyInput",
description: "Input data for creating or updating a contact attribute",
});
@@ -1,25 +1,21 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZResponse } from "@formbricks/database/zod/responses";
extendZodWithOpenApi(z);
export const ZResponseIdSchema = z
.string()
.cuid2()
.openapi({
ref: "responseId",
description: "The ID of the response",
.meta({
id: "responseId",
param: {
name: "id",
in: "path",
},
});
})
.describe("The ID of the response");
export const ZResponseUpdateSchema = ZResponse.omit({
id: true,
surveyId: true,
}).openapi({
ref: "responseUpdate",
}).meta({
id: "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.sourceType(),
query: ZGetResponsesFilter,
},
tags: ["Management API - Responses"],
responses: {
@@ -19,7 +19,7 @@ export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
query: ZGetResponsesFilter.sourceType(),
query: ZGetResponsesFilter,
},
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.string().cuid2().optional(),
surveyId: z.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.string().url(),
surveyUrl: z.url(),
expiresAt: z
.string()
.nullable()
@@ -1,23 +1,18 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZContactLinkParams = z.object({
surveyId: z
.string()
.cuid2()
.openapi({
description: "The ID of the survey",
.meta({
param: { name: "surveyId", in: "path" },
}),
})
.describe("The ID of the survey"),
contactId: z
.string()
.cuid2()
.openapi({
description: "The ID of the contact",
.meta({
param: { name: "contactId", in: "path" },
}),
})
.describe("The ID of the contact"),
});
export const ZContactLinkQuery = z.object({
@@ -1,24 +1,19 @@
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()
.openapi({
description: "The ID of the survey",
.meta({
param: { name: "surveyId", in: "path" },
}),
})
.describe("The ID of the survey"),
segmentId: z
.string()
.cuid2()
.openapi({
description: "The ID of the segment",
.meta({
param: { name: "segmentId", in: "path" },
}),
})
.describe("The ID of the segment"),
});
export const ZContactLinksBySegmentQuery = ZGetFilter.pick({
@@ -30,7 +25,7 @@ export const ZContactLinksBySegmentQuery = ZGetFilter.pick({
.min(1)
.max(365)
.nullish()
.default(null)
.prefault(null)
.describe("Number of days until the generated JWT expires. If not provided, there is no expiration."),
attributeKeys: z
.string()
@@ -52,7 +47,7 @@ export type TContactWithAttributes = {
export const ZContactLinkResponse = z.object({
contactId: z.string().describe("The ID of the contact"),
surveyUrl: z.string().url().describe("Personalized survey link"),
surveyUrl: z.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,16 +1,12 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const surveyIdSchema = z
.string()
.cuid2()
.openapi({
ref: "surveyId",
description: "The ID of the survey",
.meta({
id: "surveyId",
param: {
name: "id",
in: "path",
},
});
})
.describe("The ID of the survey");
@@ -1,15 +1,12 @@
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().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"),
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"),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
surveyType: z.enum(["link", "app"]).optional(),
@@ -23,7 +20,7 @@ export const ZGetSurveysFilter = z
return true;
},
{
message: "startDate must be before endDate",
error: "startDate must be before endDate",
}
);
@@ -69,8 +66,8 @@ export const ZSurveyInput = ZSurveyWithoutQuestionType.pick({
inlineTriggers: true,
displayPercentage: true,
})
.openapi({
ref: "surveyInput",
.meta({
id: "surveyInput",
description: "A survey input object for creating or updating surveys",
});
@@ -1,6 +1,8 @@
import { describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { InvalidInputError } from "@formbricks/types/errors";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import {
mockedPrismaWebhookUpdateReturn,
prismaNotFoundError,
@@ -18,6 +20,10 @@ vi.mock("@formbricks/database", () => ({
},
}));
vi.mock("@/lib/utils/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn().mockResolvedValue(undefined),
}));
describe("getWebhook", () => {
test("returns ok if webhook is found", async () => {
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce({ id: "123" });
@@ -63,6 +69,44 @@ describe("updateWebhook", () => {
}
});
test("calls validateWebhookUrl when URL is provided", async () => {
vi.mocked(prisma.webhook.update).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn);
await updateWebhook("123", mockedWebhookUpdateReturn);
expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com");
});
test("returns bad_request and skips Prisma update when URL fails SSRF validation", async () => {
vi.mocked(validateWebhookUrl).mockRejectedValueOnce(
new InvalidInputError("Webhook URL must not point to private or internal IP addresses")
);
const result = await updateWebhook("123", mockedWebhookUpdateReturn);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("bad_request");
expect(result.error.details[0].field).toBe("url");
}
expect(prisma.webhook.update).not.toHaveBeenCalled();
});
test("returns internal_server_error when validateWebhookUrl throws an unexpected error", async () => {
vi.mocked(validateWebhookUrl).mockRejectedValueOnce(new Error("unexpected DNS failure"));
const result = await updateWebhook("123", mockedWebhookUpdateReturn);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details[0].field).toBe("url");
}
expect(prisma.webhook.update).not.toHaveBeenCalled();
});
test("returns not_found if record does not exist", async () => {
vi.mocked(prisma.webhook.update).mockRejectedValueOnce(prismaNotFoundError);
const result = await updateWebhook("999", mockedWebhookUpdateReturn);
@@ -3,6 +3,8 @@ import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { InvalidInputError } from "@formbricks/types/errors";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { ZWebhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
@@ -34,6 +36,23 @@ export const updateWebhook = async (
webhookId: string,
webhookInput: z.infer<typeof ZWebhookUpdateSchema>
): Promise<Result<Webhook, ApiErrorResponseV2>> => {
if (webhookInput.url) {
try {
await validateWebhookUrl(webhookInput.url);
} catch (error) {
if (error instanceof InvalidInputError) {
return err({
type: "bad_request",
details: [{ field: "url", issue: error.message }],
});
}
return err({
type: "internal_server_error",
details: [{ field: "url", issue: "Webhook URL validation failed unexpectedly" }],
});
}
}
try {
const updatedWebhook = await prisma.webhook.update({
where: {
@@ -1,20 +1,16 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
extendZodWithOpenApi(z);
export const ZWebhookIdSchema = z
.string()
.cuid2()
.openapi({
ref: "webhookId",
description: "The ID of the webhook",
.meta({
id: "webhookId",
param: {
name: "id",
in: "path",
},
});
})
.describe("The ID of the webhook");
export const ZWebhookUpdateSchema = ZWebhook.omit({
id: true,
@@ -22,7 +18,7 @@ export const ZWebhookUpdateSchema = ZWebhook.omit({
updatedAt: true,
environmentId: true,
secret: true,
}).openapi({
ref: "webhookUpdate",
}).meta({
id: "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.sourceType(),
query: ZGetWebhooksFilter,
},
tags: ["Management API - Webhooks"],
responses: {

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