mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-22 11:29:22 -05:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac208e8722 | |||
| 96f173c3b1 | |||
| 9c9e55fba6 | |||
| 42541f86fd | |||
| 0ba469a73d | |||
| afa192e5b9 | |||
| 4860a9a5cf | |||
| af02ce9ea6 | |||
| fc1c91896a | |||
| f5c7dbdc71 | |||
| b88ea5cc66 | |||
| f31085a9e7 | |||
| 2ab0441404 | |||
| 299ae81b21 | |||
| f73f13f16c | |||
| e9bcbf6e4c | |||
| 32eda35a71 | |||
| 84999cddfd | |||
| f0a0cf531a | |||
| f3e02fa466 | |||
| f0a93ae092 |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
const baseConfig = require("../../.prettierrc.js");
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
tailwindConfig: "./tailwind.config.js",
|
||||
};
|
||||
+1
-1
@@ -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`}>
|
||||
|
||||
+1
-1
@@ -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")} />
|
||||
|
||||
|
||||
+1
-1
@@ -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={"/"}>
|
||||
|
||||
+1
-1
@@ -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={"/"}>
|
||||
|
||||
+1
-1
@@ -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} />
|
||||
|
||||
+1
-1
@@ -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 />
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ const ZUpdateNotificationSettingsAction = z.object({
|
||||
});
|
||||
|
||||
export const updateNotificationSettingsAction = authenticatedActionClient
|
||||
.schema(ZUpdateNotificationSettingsAction)
|
||||
.inputSchema(ZUpdateNotificationSettingsAction)
|
||||
.action(
|
||||
withAuditLogging(
|
||||
"updated",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+49
-36
@@ -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 && (
|
||||
|
||||
+1
-1
@@ -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)}
|
||||
/>
|
||||
|
||||
+33
-25
@@ -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);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
+6
-6
@@ -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
-1
@@ -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 {
|
||||
|
||||
+5
-5
@@ -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,
|
||||
|
||||
+2
-4
@@ -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} />
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+1
-2
@@ -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>
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+2
-2
@@ -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)}%
|
||||
|
||||
+2
-3
@@ -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,
|
||||
|
||||
+2
-2
@@ -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)}%
|
||||
|
||||
+3
-4
@@ -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>
|
||||
|
||||
+2
-2
@@ -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}
|
||||
|
||||
+1
-1
@@ -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`}
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+208
@@ -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",
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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"])
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "フィードバックありがとうございます!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Спасибо за твой отзыв!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "感谢你的反馈!"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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>;
|
||||
|
||||
+6
-10
@@ -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;
|
||||
|
||||
+4
-7
@@ -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) => {
|
||||
|
||||
+1
-1
@@ -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()
|
||||
|
||||
+6
-11
@@ -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({
|
||||
|
||||
+8
-13
@@ -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
Reference in New Issue
Block a user